A community based topic aggregation platform built on atproto
1package oauth 2 3import ( 4 "Coves/internal/atproto/oauth" 5 "log" 6 "net/http" 7 "os" 8 "strings" 9 "time" 10 11 oauthCore "Coves/internal/core/oauth" 12) 13 14const ( 15 sessionName = "coves_session" 16 sessionDID = "did" 17) 18 19// CallbackHandler handles OAuth callback 20type CallbackHandler struct { 21 sessionStore oauthCore.SessionStore 22} 23 24// NewCallbackHandler creates a new callback handler 25func NewCallbackHandler(sessionStore oauthCore.SessionStore) *CallbackHandler { 26 return &CallbackHandler{ 27 sessionStore: sessionStore, 28 } 29} 30 31// HandleCallback processes the OAuth callback 32// GET /oauth/callback?code=...&state=...&iss=... 33func (h *CallbackHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { 34 // Extract query parameters 35 code := r.URL.Query().Get("code") 36 state := r.URL.Query().Get("state") 37 iss := r.URL.Query().Get("iss") 38 errorParam := r.URL.Query().Get("error") 39 errorDesc := r.URL.Query().Get("error_description") 40 41 // Check for authorization errors 42 if errorParam != "" { 43 log.Printf("OAuth error: %s - %s", errorParam, errorDesc) 44 http.Error(w, "Authorization failed", http.StatusBadRequest) 45 return 46 } 47 48 // Validate required parameters 49 if code == "" || state == "" || iss == "" { 50 http.Error(w, "Missing required OAuth parameters", http.StatusBadRequest) 51 return 52 } 53 54 // Retrieve and delete OAuth request atomically to prevent replay attacks 55 oauthReq, err := h.sessionStore.GetAndDeleteRequest(state) 56 if err != nil { 57 log.Printf("Failed to retrieve OAuth request for state %s: %v", state, err) 58 http.Error(w, "Invalid or expired authorization request", http.StatusBadRequest) 59 return 60 } 61 62 // Verify issuer matches 63 if iss != oauthReq.AuthServerIss { 64 log.Printf("Issuer mismatch: expected %s, got %s", oauthReq.AuthServerIss, iss) 65 http.Error(w, "Authorization server mismatch", http.StatusBadRequest) 66 return 67 } 68 69 // Get OAuth client configuration (supports base64 encoding) 70 privateJWK, err := GetEnvBase64OrPlain("OAUTH_PRIVATE_JWK") 71 if err != nil { 72 log.Printf("Failed to load OAuth private key: %v", err) 73 http.Error(w, "OAuth configuration error", http.StatusInternalServerError) 74 return 75 } 76 if privateJWK == "" { 77 http.Error(w, "OAuth not configured", http.StatusInternalServerError) 78 return 79 } 80 81 privateKey, err := oauth.ParseJWKFromJSON([]byte(privateJWK)) 82 if err != nil { 83 log.Printf("Failed to parse OAuth private key: %v", err) 84 http.Error(w, "OAuth configuration error", http.StatusInternalServerError) 85 return 86 } 87 88 appviewURL := getAppViewURL() 89 clientID := getClientID(appviewURL) 90 redirectURI := appviewURL + "/oauth/callback" 91 92 // Create OAuth client 93 client := oauth.NewClient(clientID, privateKey, redirectURI) 94 95 // Parse DPoP key from OAuth request 96 dpopKey, err := oauth.ParseJWKFromJSON([]byte(oauthReq.DPoPPrivateJWK)) 97 if err != nil { 98 log.Printf("Failed to parse DPoP key: %v", err) 99 http.Error(w, "Failed to restore session key", http.StatusInternalServerError) 100 return 101 } 102 103 // Exchange authorization code for tokens 104 tokenResp, err := client.InitialTokenRequest( 105 r.Context(), 106 code, 107 oauthReq.AuthServerIss, 108 oauthReq.PKCEVerifier, 109 oauthReq.DPoPAuthServerNonce, 110 dpopKey, 111 ) 112 if err != nil { 113 log.Printf("Failed to exchange code for tokens: %v", err) 114 http.Error(w, "Failed to obtain access tokens", http.StatusInternalServerError) 115 return 116 } 117 118 // Verify token type is DPoP 119 if tokenResp.TokenType != "DPoP" { 120 log.Printf("Expected DPoP token type, got: %s", tokenResp.TokenType) 121 http.Error(w, "Invalid token type", http.StatusInternalServerError) 122 return 123 } 124 125 // Verify subject (DID) matches 126 if tokenResp.Sub != oauthReq.DID { 127 log.Printf("DID mismatch: expected %s, got %s", oauthReq.DID, tokenResp.Sub) 128 http.Error(w, "Identity verification failed", http.StatusBadRequest) 129 return 130 } 131 132 // Calculate token expiration 133 expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) 134 135 // Serialize DPoP key for storage 136 dpopKeyJSON, err := oauth.JWKToJSON(dpopKey) 137 if err != nil { 138 log.Printf("Failed to serialize DPoP key: %v", err) 139 http.Error(w, "Failed to store session", http.StatusInternalServerError) 140 return 141 } 142 143 // Save OAuth session to database 144 session := &oauthCore.OAuthSession{ 145 DID: oauthReq.DID, 146 Handle: oauthReq.Handle, 147 PDSURL: oauthReq.PDSURL, 148 AccessToken: tokenResp.AccessToken, 149 RefreshToken: tokenResp.RefreshToken, 150 DPoPPrivateJWK: string(dpopKeyJSON), 151 DPoPAuthServerNonce: tokenResp.DpopAuthserverNonce, 152 DPoPPDSNonce: "", // Will be populated on first PDS request 153 AuthServerIss: oauthReq.AuthServerIss, 154 ExpiresAt: expiresAt, 155 } 156 157 if saveErr := h.sessionStore.SaveSession(session); saveErr != nil { 158 log.Printf("Failed to save OAuth session: %v", saveErr) 159 http.Error(w, "Failed to save session", http.StatusInternalServerError) 160 return 161 } 162 163 // Note: OAuth request already deleted atomically in GetAndDeleteRequest above 164 165 // Create HTTP session cookie 166 cookieStore := GetCookieStore() 167 httpSession, err := cookieStore.Get(r, sessionName) 168 if err != nil { 169 log.Printf("Failed to get cookie session: %v", err) 170 // Try to create a new session anyway 171 httpSession, err = cookieStore.New(r, sessionName) 172 if err != nil { 173 log.Printf("Failed to create new session: %v", err) 174 http.Error(w, "Failed to create session", http.StatusInternalServerError) 175 return 176 } 177 } 178 179 httpSession.Values[sessionDID] = oauthReq.DID 180 httpSession.Options.MaxAge = SessionMaxAge 181 httpSession.Options.HttpOnly = true 182 httpSession.Options.Secure = !isDevelopment() // HTTPS only in production 183 httpSession.Options.SameSite = http.SameSiteLaxMode 184 185 if err := httpSession.Save(r, w); err != nil { 186 log.Printf("Failed to save HTTP session: %v", err) 187 http.Error(w, "Failed to create session", http.StatusInternalServerError) 188 return 189 } 190 191 // Determine redirect URL 192 returnURL := oauthReq.ReturnURL 193 if returnURL == "" { 194 returnURL = "/" 195 } 196 197 // Redirect user back to application 198 http.Redirect(w, r, returnURL, http.StatusFound) 199} 200 201// isDevelopment checks if we're running in development mode 202func isDevelopment() bool { 203 // Explicitly check for localhost/127.0.0.1 on any port 204 appviewURL := os.Getenv("APPVIEW_PUBLIC_URL") 205 return appviewURL == "" || 206 strings.HasPrefix(appviewURL, "http://localhost:") || 207 strings.HasPrefix(appviewURL, "http://localhost/") || 208 strings.HasPrefix(appviewURL, "http://127.0.0.1:") || 209 strings.HasPrefix(appviewURL, "http://127.0.0.1/") 210}