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}