Tap is a proof-of-concept editor for screenplays formatted in Fountain markup. It stores all data in AT Protocol records.

Better session management

Changed files
+676 -506
server
web
components
+4 -4
.gitignore
··· 19 19 # production 20 20 /build 21 21 22 + # keys 23 + *.b64 24 + *.pem 25 + 22 26 # misc 23 27 .DS_Store 24 - *.pem 25 28 26 29 # debug 27 30 npm-debug.log* ··· 31 34 32 35 # env files (can opt-in for committing if needed) 33 36 .env* 34 - 35 - # vercel 36 - .vercel 37 37 38 38 # typescript 39 39 *.tsbuildinfo
+540 -489
server/main.go
··· 44 44 // - POST: set/replace session from JSON body {did, handle, accessJwt, refreshJwt} 45 45 // - DELETE: clear session 46 46 func handleATPSession(w http.ResponseWriter, r *http.Request) { 47 - w.Header().Set("Content-Type", "application/json; charset=utf-8") 48 - sid := getOrCreateSessionID(w, r) 47 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 48 + sid := getOrCreateSessionID(w, r) 49 49 50 - switch r.Method { 51 - case http.MethodGet: 52 - sessionsMu.Lock() 53 - s, ok := userSessions[sid] 54 - sessionsMu.Unlock() 55 - if !ok || s.DID == "" { 56 - w.WriteHeader(http.StatusNoContent) 57 - return 58 - } 59 - _ = json.NewEncoder(w).Encode(s) 60 - case http.MethodPost: 61 - // Limit body size for session payload 62 - r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 63 - var s Session 64 - if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 65 - http.Error(w, "invalid json", http.StatusBadRequest) 66 - return 67 - } 68 - if s.Handle == "" || s.DID == "" { 69 - http.Error(w, "missing did/handle", http.StatusBadRequest) 70 - return 71 - } 72 - sessionsMu.Lock() 73 - userSessions[sid] = s 74 - sessionsMu.Unlock() 75 - w.WriteHeader(http.StatusNoContent) 76 - case http.MethodDelete: 77 - sessionsMu.Lock() 78 - delete(userSessions, sid) 79 - sessionsMu.Unlock() 80 - w.WriteHeader(http.StatusNoContent) 81 - default: 82 - w.WriteHeader(http.StatusMethodNotAllowed) 83 - } 50 + switch r.Method { 51 + case http.MethodGet: 52 + sessionsMu.Lock() 53 + s, ok := userSessions[sid] 54 + sessionsMu.Unlock() 55 + if !ok || s.DID == "" { 56 + w.WriteHeader(http.StatusNoContent) 57 + return 58 + } 59 + _ = json.NewEncoder(w).Encode(s) 60 + case http.MethodPost: 61 + // Limit body size for session payload 62 + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) // 16 KiB 63 + var s Session 64 + if err := json.NewDecoder(r.Body).Decode(&s); err != nil { 65 + http.Error(w, "invalid json", http.StatusBadRequest) 66 + return 67 + } 68 + if s.Handle == "" || s.DID == "" { 69 + http.Error(w, "missing did/handle", http.StatusBadRequest) 70 + return 71 + } 72 + sessionsMu.Lock() 73 + userSessions[sid] = s 74 + sessionsMu.Unlock() 75 + w.WriteHeader(http.StatusNoContent) 76 + case http.MethodDelete: 77 + sessionsMu.Lock() 78 + delete(userSessions, sid) 79 + sessionsMu.Unlock() 80 + w.WriteHeader(http.StatusNoContent) 81 + default: 82 + w.WriteHeader(http.StatusMethodNotAllowed) 83 + } 84 84 } 85 85 86 86 // resolveHandle resolves a Bluesky handle to a DID using the public AppView endpoint. ··· 97 97 b, _ := io.ReadAll(res.Body) 98 98 return "", fmt.Errorf("resolveHandle %d: %s", res.StatusCode, string(b)) 99 99 } 100 - var out struct{ Did string `json:"did"` } 100 + var out struct { 101 + Did string `json:"did"` 102 + } 101 103 if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 102 104 return "", err 103 105 } ··· 132 134 // handleOAuthLogin starts the OAuth flow by redirecting the user to Bluesky's authorization endpoint. 133 135 // Expects: GET /oauth/login?handle=<bsky-handle>&return_url=<optional> 134 136 func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { 135 - if r.Method != http.MethodGet { 136 - w.WriteHeader(http.StatusMethodNotAllowed) 137 - return 138 - } 139 - handle := r.URL.Query().Get("handle") 140 - if handle == "" { 141 - http.Error(w, "missing handle", http.StatusBadRequest) 142 - return 143 - } 144 - // Generate state and PKCE 145 - state := generateState() 146 - verifier, challenge, err := generatePKCE() 147 - if err != nil { 148 - http.Error(w, "internal error", http.StatusInternalServerError) 149 - return 150 - } 151 - // Resolve PDS to request correct OAuth audience at authorization step 152 - resourcePDS := "https://bsky.social" 153 - if did, err := resolveHandle(handle); err == nil && strings.HasPrefix(did, "did:plc:") { 154 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 155 - resourcePDS = u 156 - } 157 - } 158 - log.Printf("OAuth login: using resource=%s for authorize audience", resourcePDS) 137 + if r.Method != http.MethodGet { 138 + w.WriteHeader(http.StatusMethodNotAllowed) 139 + return 140 + } 141 + handle := r.URL.Query().Get("handle") 142 + if handle == "" { 143 + http.Error(w, "missing handle", http.StatusBadRequest) 144 + return 145 + } 146 + // Generate state and PKCE 147 + state := generateState() 148 + verifier, challenge, err := generatePKCE() 149 + if err != nil { 150 + http.Error(w, "internal error", http.StatusInternalServerError) 151 + return 152 + } 153 + // Resolve PDS to request correct OAuth audience at authorization step 154 + resourcePDS := "https://bsky.social" 155 + if did, err := resolveHandle(handle); err == nil && strings.HasPrefix(did, "did:plc:") { 156 + if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 157 + resourcePDS = u 158 + } 159 + } 160 + log.Printf("OAuth login: using resource=%s for authorize audience", resourcePDS) 159 161 160 - // Persist request in a short-lived cookie 161 - req := OAuthRequest{ 162 - State: state, 163 - Handle: handle, 164 - ReturnUrl: r.URL.Query().Get("return_url"), 165 - PkceVerifier: verifier, 166 - PkceChallenge: challenge, 167 - PdsUrl: resourcePDS, 168 - } 169 - b, _ := json.Marshal(req) 170 - http.SetCookie(w, &http.Cookie{ 171 - Name: "oauth_request", 172 - Value: base64.StdEncoding.EncodeToString(b), 173 - Path: "/", 174 - HttpOnly: true, 175 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 176 - SameSite: http.SameSiteLaxMode, 177 - MaxAge: 300, // 5 min 178 - }) 162 + // Persist request in a short-lived cookie 163 + req := OAuthRequest{ 164 + State: state, 165 + Handle: handle, 166 + ReturnUrl: r.URL.Query().Get("return_url"), 167 + PkceVerifier: verifier, 168 + PkceChallenge: challenge, 169 + PdsUrl: resourcePDS, 170 + } 171 + b, _ := json.Marshal(req) 172 + http.SetCookie(w, &http.Cookie{ 173 + Name: "oauth_request", 174 + Value: base64.StdEncoding.EncodeToString(b), 175 + Path: "/", 176 + HttpOnly: true, 177 + Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 178 + SameSite: http.SameSiteLaxMode, 179 + MaxAge: 300, // 5 min 180 + }) 179 181 180 - // Build authorize URL (include resource to bind audience up-front) 181 - authURL := "https://bsky.social/oauth/authorize?" + url.Values{ 182 - "client_id": {oauthManager.clientURI + "/oauth/client-metadata.json"}, 183 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 184 - "response_type": {"code"}, 185 - "scope": {oauthScope}, 186 - "state": {state}, 187 - "code_challenge": {challenge}, 188 - "code_challenge_method": {"S256"}, 189 - "resource": {resourcePDS}, 190 - }.Encode() 182 + // Build authorize URL (include resource to bind audience up-front) 183 + authURL := "https://bsky.social/oauth/authorize?" + url.Values{ 184 + "client_id": {oauthManager.clientURI + "/oauth/client-metadata.json"}, 185 + "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 186 + "response_type": {"code"}, 187 + "scope": {oauthScope}, 188 + "state": {state}, 189 + "code_challenge": {challenge}, 190 + "code_challenge_method": {"S256"}, 191 + "resource": {resourcePDS}, 192 + }.Encode() 191 193 192 - http.Redirect(w, r, authURL, http.StatusFound) 194 + http.Redirect(w, r, authURL, http.StatusFound) 193 195 } 194 196 195 197 // Serve OAuth client metadata (Bluesky will fetch this) 196 198 func handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { 197 - w.Header().Set("Content-Type", "application/json") 198 - _ = json.NewEncoder(w).Encode(oauthManager.ClientMetadata()) 199 + w.Header().Set("Content-Type", "application/json") 200 + _ = json.NewEncoder(w).Encode(oauthManager.ClientMetadata()) 199 201 } 200 202 201 203 // Serve JWKS for our private_key_jwt key 202 204 func handleOAuthJWKS(w http.ResponseWriter, r *http.Request) { 203 - w.Header().Set("Content-Type", "application/json") 204 - _, _ = w.Write([]byte(oauthManager.jwks)) 205 + w.Header().Set("Content-Type", "application/json") 206 + _, _ = w.Write([]byte(oauthManager.jwks)) 205 207 } 206 208 207 209 // Minimal state generator for OAuth 208 210 func generateState() string { 209 - b := make([]byte, 16) 210 - _, _ = rand.Read(b) 211 - return hex.EncodeToString(b) 211 + b := make([]byte, 16) 212 + _, _ = rand.Read(b) 213 + return hex.EncodeToString(b) 212 214 } 213 215 214 216 // NOTE: Minimal placeholder — implements a visible endpoint so the authorize 215 217 // redirect can come back. Full token exchange can be restored later. 216 218 func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { 217 - if r.Method != http.MethodGet { 218 - w.WriteHeader(http.StatusMethodNotAllowed) 219 - return 220 - } 221 - code := r.URL.Query().Get("code") 222 - state := r.URL.Query().Get("state") 223 - if code == "" || state == "" { 224 - http.Error(w, "missing code or state", http.StatusBadRequest) 225 - return 226 - } 227 - // Load request from cookie 228 - c, err := r.Cookie("oauth_request") 229 - if err != nil { 230 - http.Error(w, "invalid state", http.StatusBadRequest) 231 - return 232 - } 233 - raw, err := base64.StdEncoding.DecodeString(c.Value) 234 - if err != nil { 235 - http.Error(w, "invalid state", http.StatusBadRequest) 236 - return 237 - } 238 - var ore OAuthRequest 239 - if err := json.Unmarshal(raw, &ore); err != nil { 240 - http.Error(w, "invalid state", http.StatusBadRequest) 241 - return 242 - } 243 - if ore.State != state { 244 - http.Error(w, "invalid state", http.StatusBadRequest) 245 - return 246 - } 219 + if r.Method != http.MethodGet { 220 + w.WriteHeader(http.StatusMethodNotAllowed) 221 + return 222 + } 223 + code := r.URL.Query().Get("code") 224 + state := r.URL.Query().Get("state") 225 + if code == "" || state == "" { 226 + http.Error(w, "missing code or state", http.StatusBadRequest) 227 + return 228 + } 229 + // Load request from cookie 230 + c, err := r.Cookie("oauth_request") 231 + if err != nil { 232 + http.Error(w, "invalid state", http.StatusBadRequest) 233 + return 234 + } 235 + raw, err := base64.StdEncoding.DecodeString(c.Value) 236 + if err != nil { 237 + http.Error(w, "invalid state", http.StatusBadRequest) 238 + return 239 + } 240 + var ore OAuthRequest 241 + if err := json.Unmarshal(raw, &ore); err != nil { 242 + http.Error(w, "invalid state", http.StatusBadRequest) 243 + return 244 + } 245 + if ore.State != state { 246 + http.Error(w, "invalid state", http.StatusBadRequest) 247 + return 248 + } 247 249 248 - // Resolve user's PDS base upfront from handle so we can request the correct token audience via 'resource' 249 - var resourcePDS string 250 - if ore.PdsUrl != "" { 251 - resourcePDS = ore.PdsUrl 252 - } else if ore.Handle != "" { 253 - if did, err := resolveHandle(ore.Handle); err == nil && strings.HasPrefix(did, "did:plc:") { 254 - if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 255 - resourcePDS = u 256 - } 257 - } 258 - } 259 - if resourcePDS == "" { 260 - resourcePDS = "https://bsky.social" 261 - } 262 - log.Printf("OAuth callback: using resource=%s for token audience", resourcePDS) 250 + // Resolve user's PDS base upfront from handle so we can request the correct token audience via 'resource' 251 + var resourcePDS string 252 + if ore.PdsUrl != "" { 253 + resourcePDS = ore.PdsUrl 254 + } else if ore.Handle != "" { 255 + if did, err := resolveHandle(ore.Handle); err == nil && strings.HasPrefix(did, "did:plc:") { 256 + if u, err2 := resolvePDSFromPLC(did); err2 == nil && u != "" { 257 + resourcePDS = u 258 + } 259 + } 260 + } 261 + if resourcePDS == "" { 262 + resourcePDS = "https://bsky.social" 263 + } 264 + log.Printf("OAuth callback: using resource=%s for token audience", resourcePDS) 263 265 264 - // Token exchange 265 - tokenURL := "https://bsky.social/oauth/token" 266 - clientID := oauthManager.clientURI + "/oauth/client-metadata.json" 267 - form := url.Values{ 268 - "grant_type": {"authorization_code"}, 269 - "code": {code}, 270 - "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 271 - "code_verifier": {ore.PkceVerifier}, 272 - "client_id": {clientID}, 273 - // Request default OAuth scope and set resource to user's PDS so access token is audience-bound correctly 274 - "scope": {oauthScope}, 275 - "resource": {resourcePDS}, 276 - } 277 - assertion, err := oauthManager.generateClientAssertion(clientID, tokenURL) 278 - if err != nil { 279 - http.Error(w, "token exchange failed", http.StatusInternalServerError) 280 - return 281 - } 282 - form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 283 - form.Set("client_assertion", assertion) 266 + // Token exchange 267 + tokenURL := "https://bsky.social/oauth/token" 268 + clientID := oauthManager.clientURI + "/oauth/client-metadata.json" 269 + form := url.Values{ 270 + "grant_type": {"authorization_code"}, 271 + "code": {code}, 272 + "redirect_uri": {oauthManager.clientURI + "/oauth/callback"}, 273 + "code_verifier": {ore.PkceVerifier}, 274 + "client_id": {clientID}, 275 + // Request default OAuth scope and set resource to user's PDS so access token is audience-bound correctly 276 + "scope": {oauthScope}, 277 + "resource": {resourcePDS}, 278 + } 279 + assertion, err := oauthManager.generateClientAssertion(clientID, tokenURL) 280 + if err != nil { 281 + http.Error(w, "token exchange failed", http.StatusInternalServerError) 282 + return 283 + } 284 + form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 285 + form.Set("client_assertion", assertion) 284 286 285 - // Build DPoP proof and send (with nonce retry) 286 - buildReq := func(dpop string) (*http.Request, error) { 287 - req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) 288 - if err != nil { return nil, err } 289 - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 290 - req.Header.Set("Accept", "application/json") 291 - if dpop != "" { req.Header.Set("DPoP", dpop) } 292 - return req, nil 293 - } 294 - // 1st attempt 295 - dpop, err := oauthManager.generateDPoPProof("POST", tokenURL, "") 296 - if err != nil { 297 - http.Error(w, "dpop generation failed", http.StatusInternalServerError) 298 - return 299 - } 300 - req1, _ := buildReq(dpop) 301 - res, err := http.DefaultClient.Do(req1) 302 - if err != nil { 303 - http.Error(w, "token exchange failed", http.StatusBadGateway) 304 - return 305 - } 306 - body, _ := io.ReadAll(res.Body) 307 - res.Body.Close() 308 - log.Printf("OAuth callback: token exchange attempt1 status=%d, body=%s", res.StatusCode, string(body)) 309 - if n := res.Header.Get("DPoP-Nonce"); n != "" { log.Printf("OAuth callback: received DPoP-Nonce: %s", n) } 310 - // use_dpop_nonce retry 311 - if res.StatusCode == http.StatusBadRequest { 312 - var er struct{ Error string `json:"error"` } 313 - _ = json.Unmarshal(body, &er) 314 - if er.Error == "use_dpop_nonce" { 315 - if nonce := res.Header.Get("DPoP-Nonce"); nonce != "" { 316 - dpop2, err2 := oauthManager.generateDPoPProof("POST", tokenURL, nonce) 317 - if err2 != nil { 318 - http.Error(w, "dpop regeneration failed", http.StatusInternalServerError) 319 - return 320 - } 321 - req2, _ := buildReq(dpop2) 322 - res, err = http.DefaultClient.Do(req2) 323 - if err != nil { 324 - http.Error(w, "token exchange failed", http.StatusBadGateway) 325 - return 326 - } 327 - body, _ = io.ReadAll(res.Body) 328 - res.Body.Close() 329 - log.Printf("OAuth callback: token exchange attempt2 status=%d, body=%s", res.StatusCode, string(body)) 330 - } 331 - } 332 - } 333 - if res.StatusCode != http.StatusOK { 334 - log.Printf("OAuth callback: token exchange failed final status=%d, body=%s", res.StatusCode, string(body)) 335 - http.Error(w, "token exchange failed", http.StatusBadRequest) 336 - return 337 - } 338 - // Parse tokens 339 - var tok struct { 340 - AccessToken string `json:"access_token"` 341 - RefreshToken string `json:"refresh_token"` 342 - TokenType string `json:"token_type"` 343 - ExpiresIn int `json:"expires_in"` 344 - Scope string `json:"scope"` 345 - Sub string `json:"sub"` 346 - Aud string `json:"aud"` 347 - } 348 - if err := json.Unmarshal(body, &tok); err != nil { 349 - http.Error(w, "token decode failed", http.StatusInternalServerError) 350 - return 351 - } 352 - if tok.Sub == "" { 353 - http.Error(w, "token decode failed", http.StatusInternalServerError) 354 - return 355 - } 356 - // Derive PDS base: 357 - // 1) If aud indicates did:web, use that host 358 - // 2) Else, resolve from DID (plc) document service endpoint #atproto_pds 359 - // 3) Fallback to bsky.social 360 - pdsURL := "https://bsky.social" 361 - if strings.HasPrefix(tok.Aud, "did:web:") { 362 - host := strings.TrimPrefix(tok.Aud, "did:web:") 363 - if host != "" { 364 - pdsURL = "https://" + host 365 - } 366 - } else if strings.HasPrefix(tok.Sub, "did:plc:") { 367 - if u, err := resolvePDSFromPLC(tok.Sub); err == nil && u != "" { 368 - pdsURL = u 369 - } else if err != nil { 370 - log.Printf("OAuth callback: PLC resolve failed for %s: %v", tok.Sub, err) 371 - } 372 - } 373 - log.Printf("OAuth callback: token aud=%q, chosen PDS base=%s, token_type=%s", tok.Aud, pdsURL, tok.TokenType) 374 - // Save OAuth session 375 - sess := OAuthSession{ 376 - Did: tok.Sub, 377 - Handle: ore.Handle, 378 - PdsUrl: pdsURL, 379 - TokenType: tok.TokenType, 380 - Scope: tok.Scope, 381 - AccessJwt: tok.AccessToken, 382 - RefreshJwt: tok.RefreshToken, 383 - Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), 384 - } 385 - oauthManager.SaveSession(sess.Did, sess) 386 - // Save cookie session 387 - userSession, _ := oauthManager.store.Get(r, SessionName) 388 - userSession.Values["did"] = sess.Did 389 - userSession.Values["handle"] = sess.Handle 390 - userSession.Values["authenticated"] = true 391 - _ = userSession.Save(r, w) 287 + // Build DPoP proof and send (with nonce retry) 288 + buildReq := func(dpop string) (*http.Request, error) { 289 + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) 290 + if err != nil { 291 + return nil, err 292 + } 293 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 294 + req.Header.Set("Accept", "application/json") 295 + if dpop != "" { 296 + req.Header.Set("DPoP", dpop) 297 + } 298 + return req, nil 299 + } 300 + // 1st attempt 301 + dpop, err := oauthManager.generateDPoPProof("POST", tokenURL, "") 302 + if err != nil { 303 + http.Error(w, "dpop generation failed", http.StatusInternalServerError) 304 + return 305 + } 306 + req1, _ := buildReq(dpop) 307 + res, err := http.DefaultClient.Do(req1) 308 + if err != nil { 309 + http.Error(w, "token exchange failed", http.StatusBadGateway) 310 + return 311 + } 312 + body, _ := io.ReadAll(res.Body) 313 + res.Body.Close() 314 + log.Printf("OAuth callback: token exchange attempt1 status=%d, body=%s", res.StatusCode, string(body)) 315 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 316 + log.Printf("OAuth callback: received DPoP-Nonce: %s", n) 317 + } 318 + // use_dpop_nonce retry 319 + if res.StatusCode == http.StatusBadRequest { 320 + var er struct { 321 + Error string `json:"error"` 322 + } 323 + _ = json.Unmarshal(body, &er) 324 + if er.Error == "use_dpop_nonce" { 325 + if nonce := res.Header.Get("DPoP-Nonce"); nonce != "" { 326 + dpop2, err2 := oauthManager.generateDPoPProof("POST", tokenURL, nonce) 327 + if err2 != nil { 328 + http.Error(w, "dpop regeneration failed", http.StatusInternalServerError) 329 + return 330 + } 331 + req2, _ := buildReq(dpop2) 332 + res, err = http.DefaultClient.Do(req2) 333 + if err != nil { 334 + http.Error(w, "token exchange failed", http.StatusBadGateway) 335 + return 336 + } 337 + body, _ = io.ReadAll(res.Body) 338 + res.Body.Close() 339 + log.Printf("OAuth callback: token exchange attempt2 status=%d, body=%s", res.StatusCode, string(body)) 340 + } 341 + } 342 + } 343 + if res.StatusCode != http.StatusOK { 344 + log.Printf("OAuth callback: token exchange failed final status=%d, body=%s", res.StatusCode, string(body)) 345 + http.Error(w, "token exchange failed", http.StatusBadRequest) 346 + return 347 + } 348 + // Parse tokens 349 + var tok struct { 350 + AccessToken string `json:"access_token"` 351 + RefreshToken string `json:"refresh_token"` 352 + TokenType string `json:"token_type"` 353 + ExpiresIn int `json:"expires_in"` 354 + Scope string `json:"scope"` 355 + Sub string `json:"sub"` 356 + Aud string `json:"aud"` 357 + } 358 + if err := json.Unmarshal(body, &tok); err != nil { 359 + http.Error(w, "token decode failed", http.StatusInternalServerError) 360 + return 361 + } 362 + if tok.Sub == "" { 363 + http.Error(w, "token decode failed", http.StatusInternalServerError) 364 + return 365 + } 366 + // Derive PDS base: 367 + // 1) If aud indicates did:web, use that host 368 + // 2) Else, resolve from DID (plc) document service endpoint #atproto_pds 369 + // 3) Fallback to bsky.social 370 + pdsURL := "https://bsky.social" 371 + if strings.HasPrefix(tok.Aud, "did:web:") { 372 + host := strings.TrimPrefix(tok.Aud, "did:web:") 373 + if host != "" { 374 + pdsURL = "https://" + host 375 + } 376 + } else if strings.HasPrefix(tok.Sub, "did:plc:") { 377 + if u, err := resolvePDSFromPLC(tok.Sub); err == nil && u != "" { 378 + pdsURL = u 379 + } else if err != nil { 380 + log.Printf("OAuth callback: PLC resolve failed for %s: %v", tok.Sub, err) 381 + } 382 + } 383 + log.Printf("OAuth callback: token aud=%q, chosen PDS base=%s, token_type=%s", tok.Aud, pdsURL, tok.TokenType) 384 + // Save OAuth session 385 + sess := OAuthSession{ 386 + Did: tok.Sub, 387 + Handle: ore.Handle, 388 + PdsUrl: pdsURL, 389 + TokenType: tok.TokenType, 390 + Scope: tok.Scope, 391 + AccessJwt: tok.AccessToken, 392 + RefreshJwt: tok.RefreshToken, 393 + Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second), 394 + } 395 + oauthManager.SaveSession(sess.Did, sess) 396 + // Save cookie session 397 + userSession, _ := oauthManager.store.Get(r, SessionName) 398 + userSession.Values["did"] = sess.Did 399 + userSession.Values["handle"] = sess.Handle 400 + userSession.Values["authenticated"] = true 401 + _ = userSession.Save(r, w) 392 402 393 - // Also set legacy session cookie used by /atp/session 394 - sid := "oauth_session_" + sess.Handle 395 - sessionsMu.Lock() 396 - userSessions[sid] = Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt} 397 - sessionsMu.Unlock() 398 - // Set legacy session cookie used by getOrCreateSessionID()/atp/session 399 - http.SetCookie(w, &http.Cookie{ 400 - Name: "tap_session", 401 - Value: sid, 402 - Path: "/", 403 - HttpOnly: true, 404 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 405 - SameSite: http.SameSiteLaxMode, 406 - Expires: time.Now().Add(30 * 24 * time.Hour), 407 - }) 408 - // Clear the oauth_request cookie 409 - http.SetCookie(w, &http.Cookie{ 410 - Name: "oauth_request", 411 - Value: "", 412 - Path: "/", 413 - HttpOnly: true, 414 - Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 415 - SameSite: http.SameSiteLaxMode, 416 - MaxAge: -1, 417 - }) 418 - // Post-login sanity: validate token can call PDS using DPoP on describeRepo 419 - go func(did, pds string) { 420 - // Give a tiny delay to avoid racing cookie writes in some environments 421 - time.Sleep(200 * time.Millisecond) 422 - sanityURL := pds + "/xrpc/com.atproto.repo.describeRepo?repo=" + url.QueryEscape(did) 423 - log.Printf("auth sanity: calling describeRepo for %s at %s", did, sanityURL) 424 - resp, err := pdsRequest(w, r, http.MethodGet, sanityURL, "", nil) 425 - if err != nil { 426 - log.Printf("auth sanity: request error: %v", err) 427 - return 428 - } 429 - defer resp.Body.Close() 430 - b, _ := io.ReadAll(resp.Body) 431 - if resp.StatusCode < 200 || resp.StatusCode >= 300 { 432 - log.Printf("auth sanity: describeRepo -> %d body=%s", resp.StatusCode, string(b)) 433 - } else { 434 - log.Printf("auth sanity: describeRepo OK -> %d", resp.StatusCode) 435 - } 436 - }(sess.Did, sess.PdsUrl) 403 + // Also set legacy session cookie used by /atp/session 404 + sid := "oauth_session_" + sess.Handle 405 + sessionsMu.Lock() 406 + userSessions[sid] = Session{DID: sess.Did, Handle: sess.Handle, AccessJWT: sess.AccessJwt, RefreshJWT: sess.RefreshJwt} 407 + sessionsMu.Unlock() 408 + // Set legacy session cookie used by getOrCreateSessionID()/atp/session 409 + http.SetCookie(w, &http.Cookie{ 410 + Name: "tap_session", 411 + Value: sid, 412 + Path: "/", 413 + HttpOnly: true, 414 + Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 415 + SameSite: http.SameSiteLaxMode, 416 + Expires: time.Now().Add(30 * 24 * time.Hour), 417 + }) 418 + // Clear the oauth_request cookie 419 + http.SetCookie(w, &http.Cookie{ 420 + Name: "oauth_request", 421 + Value: "", 422 + Path: "/", 423 + HttpOnly: true, 424 + Secure: strings.HasPrefix(oauthManager.clientURI, "https://"), 425 + SameSite: http.SameSiteLaxMode, 426 + MaxAge: -1, 427 + }) 428 + // Post-login sanity: validate token can call PDS using DPoP on describeRepo 429 + go func(did, pds string) { 430 + // Give a tiny delay to avoid racing cookie writes in some environments 431 + time.Sleep(200 * time.Millisecond) 432 + sanityURL := pds + "/xrpc/com.atproto.repo.describeRepo?repo=" + url.QueryEscape(did) 433 + log.Printf("auth sanity: calling describeRepo for %s at %s", did, sanityURL) 434 + resp, err := pdsRequest(w, r, http.MethodGet, sanityURL, "", nil) 435 + if err != nil { 436 + log.Printf("auth sanity: request error: %v", err) 437 + return 438 + } 439 + defer resp.Body.Close() 440 + b, _ := io.ReadAll(resp.Body) 441 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 442 + log.Printf("auth sanity: describeRepo -> %d body=%s", resp.StatusCode, string(b)) 443 + } else { 444 + log.Printf("auth sanity: describeRepo OK -> %d", resp.StatusCode) 445 + } 446 + }(sess.Did, sess.PdsUrl) 437 447 438 - // Redirect back 439 - ret := ore.ReturnUrl 440 - if ret == "" { ret = "/" } 441 - http.Redirect(w, r, ret, http.StatusFound) 448 + // Redirect back 449 + ret := ore.ReturnUrl 450 + if ret == "" { 451 + ret = "/" 452 + } 453 + http.Redirect(w, r, ret, http.StatusFound) 442 454 } 443 455 444 456 func handleOAuthLogout(w http.ResponseWriter, r *http.Request) { 445 - if r.Method != http.MethodPost { 446 - w.WriteHeader(http.StatusMethodNotAllowed) 447 - return 448 - } 449 - http.Redirect(w, r, "/", http.StatusFound) 457 + if r.Method != http.MethodPost { 458 + w.WriteHeader(http.StatusMethodNotAllowed) 459 + return 460 + } 461 + http.Redirect(w, r, "/", http.StatusFound) 450 462 } 451 463 452 464 // pdsBaseFromUser returns the user's PDS base if an OAuth session exists; otherwise bsky.social. 453 465 func pdsBaseFromUser(r *http.Request) string { 454 - if oauthManager != nil { 455 - if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { 456 - return u.Pds 457 - } 458 - } 459 - return "https://bsky.social" 466 + if oauthManager != nil { 467 + if u := oauthManager.GetUser(r); u != nil && u.Pds != "" { 468 + return u.Pds 469 + } 470 + } 471 + return "https://bsky.social" 460 472 } 461 473 462 474 // resolvePDSFromPLC fetches the DID PLC document and returns the atproto_pds service endpoint if present. 463 475 func resolvePDSFromPLC(did string) (string, error) { 464 - // Example: https://plc.directory/did:plc:xyz 465 - url := "https://plc.directory/" + did 466 - req, _ := http.NewRequest(http.MethodGet, url, nil) 467 - req.Header.Set("Accept", "application/json") 468 - res, err := http.DefaultClient.Do(req) 469 - if err != nil { 470 - return "", err 471 - } 472 - defer res.Body.Close() 473 - if res.StatusCode != http.StatusOK { 474 - b, _ := io.ReadAll(res.Body) 475 - return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 476 - } 477 - var doc struct { 478 - Service []struct { 479 - ID string `json:"id"` 480 - Type string `json:"type"` 481 - ServiceEndpoint string `json:"serviceEndpoint"` 482 - } `json:"service"` 483 - } 484 - if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 485 - return "", err 486 - } 487 - for _, s := range doc.Service { 488 - if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 489 - return s.ServiceEndpoint, nil 490 - } 491 - if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { // legacy id 492 - return s.ServiceEndpoint, nil 493 - } 494 - } 495 - return "", fmt.Errorf("pds endpoint not found in DID doc") 476 + // Example: https://plc.directory/did:plc:xyz 477 + url := "https://plc.directory/" + did 478 + req, _ := http.NewRequest(http.MethodGet, url, nil) 479 + req.Header.Set("Accept", "application/json") 480 + res, err := http.DefaultClient.Do(req) 481 + if err != nil { 482 + return "", err 483 + } 484 + defer res.Body.Close() 485 + if res.StatusCode != http.StatusOK { 486 + b, _ := io.ReadAll(res.Body) 487 + return "", fmt.Errorf("plc %d: %s", res.StatusCode, string(b)) 488 + } 489 + var doc struct { 490 + Service []struct { 491 + ID string `json:"id"` 492 + Type string `json:"type"` 493 + ServiceEndpoint string `json:"serviceEndpoint"` 494 + } `json:"service"` 495 + } 496 + if err := json.NewDecoder(res.Body).Decode(&doc); err != nil { 497 + return "", err 498 + } 499 + for _, s := range doc.Service { 500 + if s.Type == "AtprotoPersonalDataServer" && s.ServiceEndpoint != "" { 501 + return s.ServiceEndpoint, nil 502 + } 503 + if s.ID == "#atproto_pds" && s.ServiceEndpoint != "" { // legacy id 504 + return s.ServiceEndpoint, nil 505 + } 506 + } 507 + return "", fmt.Errorf("pds endpoint not found in DID doc") 496 508 } 497 509 498 510 // pdsRequest sends an XRPC request to the user's PDS using dual-scheme auth: ··· 501 513 // 3) If still 400, fall back to Authorization: DPoP <token> with DPoP proof (nonce if provided) 502 514 // If no OAuth session is present, falls back to authedDo (legacy app-password flow). 503 515 func pdsRequest(w http.ResponseWriter, r *http.Request, method, url, contentType string, body []byte) (*http.Response, error) { 504 - // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 505 - var ( 506 - accToken string 507 - tokType string 508 - scopeStr string 509 - ) 510 - if oauthManager != nil { 511 - if u := oauthManager.GetUser(r); u != nil { 512 - if s, ok := oauthManager.GetSession(u.Did); ok { 513 - accToken = s.AccessJwt 514 - tokType = s.TokenType 515 - scopeStr = s.Scope 516 - } 517 - } 518 - } 519 - if accToken == "" { 520 - // try app-level session via legacy tap_session lookup helper 521 - if s, ok := getSession(r); ok && s.AccessJWT != "" { 522 - accToken = s.AccessJWT 523 - tokType = "DPoP" 524 - scopeStr = "" 525 - log.Printf("pdsRequest: using getSession() token for auth") 526 - } 527 - } 528 - if accToken == "" { 529 - // read tap_session cookie directly without creating a new one 530 - if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 531 - sessionsMu.Lock() 532 - legacy, ok := userSessions[c.Value] 533 - sessionsMu.Unlock() 534 - if ok && legacy.AccessJWT != "" { 535 - accToken = legacy.AccessJWT 536 - tokType = "DPoP" 537 - scopeStr = "" 538 - log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 539 - } 540 - } 541 - } 542 - if accToken == "" { 543 - // No token at all -> fall back to authedDo (may be anonymous) 544 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 545 - if contentType != "" { req.Header.Set("Content-Type", contentType) } 546 - req.Header.Set("Accept", "application/json") 547 - return authedDo(w, r, req) 548 - } 549 - // Builder for requests with a given scheme and optional nonce 550 - doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 551 - // Bind proof to access token via 'ath' for stricter PDSes 552 - proof, err := oauthManager.generateDPoPProofWithToken(method, url, accToken, nonce) 553 - if err != nil { return nil, nil, err } 554 - req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 555 - if contentType != "" { req.Header.Set("Content-Type", contentType) } 556 - req.Header.Set("Accept", "application/json") 557 - req.Header.Set("Authorization", scheme+" "+accToken) 558 - req.Header.Set("DPoP", proof) 559 - // Log target PDS host and path 560 - if req.URL != nil { 561 - log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 562 - } 563 - // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 564 - authHdr := req.Header.Get("Authorization") 565 - authPrefix := authHdr 566 - if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 567 - authPrefix = authHdr[:sp] 568 - } 569 - log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 570 - res, err := http.DefaultClient.Do(req) 571 - if err != nil { return nil, nil, err } 572 - b, _ := io.ReadAll(res.Body) 573 - res.Body.Close() 574 - // reattach 575 - res.Body = io.NopCloser(bytes.NewReader(b)) 576 - if res.StatusCode >= 400 { 577 - log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 578 - } 579 - return res, b, nil 580 - } 581 - // 1) Prefer DPoP token scheme (token_type is DPoP) 582 - res, b, err := doWith("DPoP", "") 583 - if err != nil { return nil, err } 584 - if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 585 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 586 - log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 587 - r2, _, e2 := doWith("DPoP", n) 588 - return r2, e2 589 - } 590 - // Some servers encode nonce hint in JSON too 591 - var er struct{ Error string `json:"error"` } 592 - _ = json.Unmarshal(b, &er) 593 - if er.Error == "use_dpop_nonce" { 594 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 595 - log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 596 - r2, _, e2 := doWith("DPoP", n) 597 - return r2, e2 598 - } 599 - } 600 - } 601 - if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 602 - return res, nil 603 - } 604 - // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 605 - if strings.EqualFold(tokType, "DPoP") { 606 - log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 607 - return res, nil 608 - } 609 - // Otherwise, try Bearer fallback 610 - log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 611 - res, b, err = doWith("Bearer", "") 612 - if err != nil { return nil, err } 613 - if res.StatusCode == http.StatusBadRequest { 614 - if n := res.Header.Get("DPoP-Nonce"); n != "" { 615 - log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 616 - r2, _, e2 := doWith("Bearer", n) 617 - return r2, e2 618 - } 619 - } 620 - return res, nil 516 + // Choose auth source: prefer OAuth session; fall back to legacy tap_session if present 517 + var ( 518 + accToken string 519 + tokType string 520 + scopeStr string 521 + ) 522 + oauthUserPresent := false 523 + if oauthManager != nil { 524 + if u := oauthManager.GetUser(r); u != nil && u.Did != "" { 525 + oauthUserPresent = true 526 + if s, ok := oauthManager.GetSession(u.Did); ok { 527 + accToken = s.AccessJwt 528 + tokType = s.TokenType 529 + scopeStr = s.Scope 530 + } else if s2, ok := oauthManager.GetSessionFromCookie(r); ok { 531 + // Rehydrate from cookie automatically 532 + oauthManager.SaveSession(s2.Did, s2) 533 + accToken = s2.AccessJwt 534 + tokType = s2.TokenType 535 + scopeStr = s2.Scope 536 + } 537 + } 538 + } 539 + // If OAuth user is present but we couldn't load tokens, do NOT silently fall back 540 + if oauthUserPresent && accToken == "" { 541 + log.Printf("pdsRequest: oauth cookie present but no tokens available; refusing legacy fallback") 542 + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{"error":"oauth_session_missing"}`))}, nil 543 + } 544 + if accToken == "" { 545 + // try app-level session via legacy tap_session lookup helper 546 + if s, ok := getSession(r); ok && s.AccessJWT != "" { 547 + accToken = s.AccessJWT 548 + tokType = "DPoP" 549 + scopeStr = "" 550 + log.Printf("pdsRequest: using getSession() token for auth") 551 + } 552 + } 553 + if accToken == "" { 554 + // read tap_session cookie directly without creating a new one 555 + if c, err := r.Cookie("tap_session"); err == nil && c != nil && c.Value != "" { 556 + sessionsMu.Lock() 557 + legacy, ok := userSessions[c.Value] 558 + sessionsMu.Unlock() 559 + if ok && legacy.AccessJWT != "" { 560 + accToken = legacy.AccessJWT 561 + tokType = "DPoP" 562 + scopeStr = "" 563 + log.Printf("pdsRequest: using legacy tap_session map token for auth (sid=%s)", c.Value) 564 + } 565 + } 566 + } 567 + if accToken == "" { 568 + // No token at all -> fall back to authedDo (may be anonymous) 569 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 570 + if contentType != "" { 571 + req.Header.Set("Content-Type", contentType) 572 + } 573 + req.Header.Set("Accept", "application/json") 574 + return authedDo(w, r, req) 575 + } 576 + // Builder for requests with a given scheme and optional nonce 577 + doWith := func(scheme, nonce string) (*http.Response, []byte, error) { 578 + // Bind proof to access token via 'ath' for stricter PDSes 579 + proof, err := oauthManager.generateDPoPProofWithToken(method, url, accToken, nonce) 580 + if err != nil { 581 + return nil, nil, err 582 + } 583 + req, _ := http.NewRequest(method, url, bytes.NewReader(body)) 584 + if contentType != "" { 585 + req.Header.Set("Content-Type", contentType) 586 + } 587 + req.Header.Set("Accept", "application/json") 588 + req.Header.Set("Authorization", scheme+" "+accToken) 589 + req.Header.Set("DPoP", proof) 590 + // Log target PDS host and path 591 + if req.URL != nil { 592 + log.Printf("pdsRequest: attempt %s %s (host=%s, scheme=%s, nonce=%t)", method, req.URL.Path, req.URL.Host, scheme, nonce != "") 593 + } 594 + // Log Authorization header prefix and token_type for diagnostics (never log the token itself) 595 + authHdr := req.Header.Get("Authorization") 596 + authPrefix := authHdr 597 + if sp := strings.IndexByte(authHdr, ' '); sp > 0 { 598 + authPrefix = authHdr[:sp] 599 + } 600 + log.Printf("pdsRequest: auth prefix=%s, session.token_type=%s, session.scope=%s", authPrefix, tokType, scopeStr) 601 + res, err := http.DefaultClient.Do(req) 602 + if err != nil { 603 + return nil, nil, err 604 + } 605 + b, _ := io.ReadAll(res.Body) 606 + res.Body.Close() 607 + // reattach 608 + res.Body = io.NopCloser(bytes.NewReader(b)) 609 + if res.StatusCode >= 400 { 610 + log.Printf("pdsRequest: %s %s -> %d (scheme=%s, nonce=%t) body=%s", method, url, res.StatusCode, scheme, nonce != "", string(b)) 611 + } 612 + return res, b, nil 613 + } 614 + // 1) Prefer DPoP token scheme (token_type is DPoP) 615 + res, b, err := doWith("DPoP", "") 616 + if err != nil { 617 + return nil, err 618 + } 619 + if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusUnauthorized { 620 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 621 + log.Printf("pdsRequest: retrying with DPoP+nonce=%s", n) 622 + r2, _, e2 := doWith("DPoP", n) 623 + return r2, e2 624 + } 625 + // Some servers encode nonce hint in JSON too 626 + var er struct { 627 + Error string `json:"error"` 628 + } 629 + _ = json.Unmarshal(b, &er) 630 + if er.Error == "use_dpop_nonce" { 631 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 632 + log.Printf("pdsRequest: retrying with DPoP+nonce(from body)=%s", n) 633 + r2, _, e2 := doWith("DPoP", n) 634 + return r2, e2 635 + } 636 + } 637 + } 638 + if res.StatusCode != http.StatusBadRequest && res.StatusCode != http.StatusUnauthorized { 639 + return res, nil 640 + } 641 + // 2) Optionally fallback to Bearer+DPoP (for older servers), but only if our token is not DPoP-bound 642 + if strings.EqualFold(tokType, "DPoP") { 643 + log.Printf("pdsRequest: token_type=DPoP, skipping Bearer fallback") 644 + return res, nil 645 + } 646 + // Otherwise, try Bearer fallback 647 + log.Printf("pdsRequest: falling back to Bearer token scheme with DPoP proof") 648 + res, b, err = doWith("Bearer", "") 649 + if err != nil { 650 + return nil, err 651 + } 652 + if res.StatusCode == http.StatusBadRequest { 653 + if n := res.Header.Get("DPoP-Nonce"); n != "" { 654 + log.Printf("pdsRequest: retrying with Bearer+DPoP nonce=%s", n) 655 + r2, _, e2 := doWith("Bearer", n) 656 + return r2, e2 657 + } 658 + } 659 + return res, nil 621 660 } 622 661 623 662 // handleATPDoc returns the current document from lol.tapapp.tap.doc/current ··· 690 729 if bRes.StatusCode >= 500 { 691 730 bRes.Body.Close() 692 731 bReq2, _ := http.NewRequest(http.MethodGet, blobURL, nil) 693 - bReq2.Header.Set("Authorization", "Bearer " + s.AccessJWT) 732 + bReq2.Header.Set("Authorization", "Bearer "+s.AccessJWT) 694 733 if bRes2, err2 := authedDo(w, r, bReq2); err2 == nil { 695 734 defer bRes2.Body.Close() 696 735 if bRes2.StatusCode >= 200 && bRes2.StatusCode < 300 { ··· 847 886 w.WriteHeader(cres.StatusCode) 848 887 w.Header().Set("Content-Type", "application/json; charset=utf-8") 849 888 // echo upstream error to client to aid debugging 850 - if len(b) > 0 { _, _ = w.Write(b) } 889 + if len(b) > 0 { 890 + _, _ = w.Write(b) 891 + } 851 892 return 852 893 } 853 894 w.WriteHeader(http.StatusCreated) ··· 1023 1064 log.Printf("putRecord failed: status=%d body=%s", pRes.StatusCode, string(pb)) 1024 1065 w.WriteHeader(pRes.StatusCode) 1025 1066 w.Header().Set("Content-Type", "application/json; charset=utf-8") 1026 - if len(pb) > 0 { _, _ = w.Write(pb) } 1067 + if len(pb) > 0 { 1068 + _, _ = w.Write(pb) 1069 + } 1027 1070 return 1028 1071 } 1029 1072 // Verify by re-reading the record and (best-effort) the blob ··· 1038 1081 w.WriteHeader(http.StatusBadGateway) 1039 1082 return 1040 1083 } 1041 - var vRec struct { Value map[string]any `json:"value"` } 1084 + var vRec struct { 1085 + Value map[string]any `json:"value"` 1086 + } 1042 1087 if e := json.NewDecoder(vRes.Body).Decode(&vRec); e != nil { 1043 1088 http.Error(w, "verify decode failed", http.StatusBadGateway) 1044 1089 return ··· 1062 1107 } 1063 1108 w.Header().Set("Content-Type", "application/json; charset=utf-8") 1064 1109 _ = json.NewEncoder(w).Encode(map[string]any{ 1065 - "id": id, 1066 - "name": vRec.Value["name"], 1067 - "text": txt, 1110 + "id": id, 1111 + "name": vRec.Value["name"], 1112 + "text": txt, 1068 1113 "updatedAt": vRec.Value["updatedAt"], 1069 1114 }) 1070 1115 return ··· 1132 1177 return 1133 1178 } 1134 1179 defer blobRes.Body.Close() 1135 - var blobResp struct { Blob map[string]any `json:"blob"` } 1180 + var blobResp struct { 1181 + Blob map[string]any `json:"blob"` 1182 + } 1136 1183 if err := json.NewDecoder(blobRes.Body).Decode(&blobResp); err != nil { 1137 1184 http.Error(w, "blob decode failed", http.StatusBadGateway) 1138 1185 return ··· 1155 1202 w.WriteHeader(http.StatusNoContent) 1156 1203 return 1157 1204 } 1158 - if pRes != nil { defer pRes.Body.Close() } 1205 + if pRes != nil { 1206 + defer pRes.Body.Close() 1207 + } 1159 1208 1160 1209 // 3) Fallback to create 1161 1210 createPayload := map[string]any{ ··· 1181 1230 } 1182 1231 1183 1232 // Initialize OAuth (required for public OAuth flow) 1184 - addr := getEnv("PORT", "8088") 1233 + addr := getEnv("PORT", "80") 1185 1234 clientURI := getEnv("CLIENT_URI", "http://localhost:"+addr) 1186 1235 cookieSecret := getEnv("COOKIE_SECRET", "your-secret-key") 1187 1236 initOAuth(clientURI, cookieSecret) ··· 1262 1311 mux.HandleFunc("/oauth/logout", handleOAuthLogout) 1263 1312 mux.HandleFunc("/oauth/client-metadata.json", handleOAuthClientMetadata) 1264 1313 mux.HandleFunc("/oauth/jwks.json", handleOAuthJWKS) 1314 + // Allow the web client to repopulate server-side OAuth session after restarts 1315 + mux.HandleFunc("/oauth/resume", handleOAuthResume) 1265 1316 1266 1317 log.Printf("tap (Go) server listening on http://localhost:%s", addr) 1267 1318 // Enforce strict redirect for legacy hosts -> tapapp.lol
+128 -9
server/oauth.go
··· 42 42 ReturnUrl string 43 43 } 44 44 45 + // handleOAuthResume allows the client to repopulate the server-side OAuth session 46 + // after restarts by POSTing current token state. Body JSON: 47 + // { 48 + // "did": "...", 49 + // "handle": "...", 50 + // "pdsUrl": "https://...", 51 + // "tokenType": "DPoP", 52 + // "scope": "atproto transition:generic", 53 + // "accessJwt": "...", 54 + // "refreshJwt": "...", 55 + // "expiry": "RFC3339 timestamp" // optional 56 + // } 57 + func handleOAuthResume(w http.ResponseWriter, r *http.Request) { 58 + if r.Method != http.MethodPost { 59 + w.WriteHeader(http.StatusMethodNotAllowed) 60 + return 61 + } 62 + if oauthManager == nil { 63 + http.Error(w, "oauth not initialized", http.StatusInternalServerError) 64 + return 65 + } 66 + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) // 32 KiB 67 + var in struct { 68 + Did string `json:"did"` 69 + Handle string `json:"handle"` 70 + PdsUrl string `json:"pdsUrl"` 71 + TokenType string `json:"tokenType"` 72 + Scope string `json:"scope"` 73 + AccessJwt string `json:"accessJwt"` 74 + RefreshJwt string `json:"refreshJwt"` 75 + Expiry string `json:"expiry"` 76 + } 77 + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { 78 + http.Error(w, "invalid json", http.StatusBadRequest) 79 + return 80 + } 81 + if in.Did == "" || in.AccessJwt == "" { 82 + http.Error(w, "missing did/accessJwt", http.StatusBadRequest) 83 + return 84 + } 85 + var exp time.Time 86 + if strings.TrimSpace(in.Expiry) != "" { 87 + if t, err := time.Parse(time.RFC3339, in.Expiry); err == nil { 88 + exp = t 89 + } 90 + } 91 + sess := OAuthSession{ 92 + Did: in.Did, 93 + Handle: in.Handle, 94 + PdsUrl: in.PdsUrl, 95 + TokenType: in.TokenType, 96 + Scope: in.Scope, 97 + AccessJwt: in.AccessJwt, 98 + RefreshJwt: in.RefreshJwt, 99 + Expiry: exp, 100 + } 101 + // Save to memory and cookie 102 + oauthManager.SaveSession(sess.Did, sess) 103 + if err := oauthManager.SaveSessionToCookie(r, w, sess); err != nil { 104 + http.Error(w, "failed to persist session", http.StatusInternalServerError) 105 + return 106 + } 107 + w.WriteHeader(http.StatusNoContent) 108 + } 109 + 110 + // GetSessionFromCookie reconstructs an OAuthSession from the Gorilla cookie, if present. 111 + func (o *OAuthManager) GetSessionFromCookie(r *http.Request) (OAuthSession, bool) { 112 + session, err := o.store.Get(r, SessionName) 113 + if err != nil || session.IsNew { 114 + return OAuthSession{}, false 115 + } 116 + did, _ := session.Values["did"].(string) 117 + handle, _ := session.Values["handle"].(string) 118 + pds, _ := session.Values["pds"].(string) 119 + ttype, _ := session.Values["token_type"].(string) 120 + scope, _ := session.Values["scope"].(string) 121 + access, _ := session.Values["access_jwt"].(string) 122 + refresh, _ := session.Values["refresh_jwt"].(string) 123 + expStr, _ := session.Values["expiry"].(string) 124 + var exp time.Time 125 + if expStr != "" { 126 + if t, err := time.Parse(time.RFC3339, expStr); err == nil { 127 + exp = t 128 + } 129 + } 130 + if did == "" || access == "" { 131 + return OAuthSession{}, false 132 + } 133 + return OAuthSession{ 134 + Did: did, 135 + Handle: handle, 136 + PdsUrl: pds, 137 + TokenType: ttype, 138 + Scope: scope, 139 + AccessJwt: access, 140 + RefreshJwt: refresh, 141 + Expiry: exp, 142 + }, true 143 + } 144 + 145 + // SaveSessionToCookie writes the OAuthSession fields into the Gorilla cookie for persistence. 146 + func (o *OAuthManager) SaveSessionToCookie(r *http.Request, w http.ResponseWriter, sess OAuthSession) error { 147 + session, err := o.store.Get(r, SessionName) 148 + if err != nil { 149 + return err 150 + } 151 + session.Values["did"] = sess.Did 152 + session.Values["handle"] = sess.Handle 153 + session.Values["pds"] = sess.PdsUrl 154 + session.Values["token_type"] = sess.TokenType 155 + session.Values["scope"] = sess.Scope 156 + session.Values["access_jwt"] = sess.AccessJwt 157 + session.Values["refresh_jwt"] = sess.RefreshJwt 158 + if !sess.Expiry.IsZero() { 159 + session.Values["expiry"] = sess.Expiry.UTC().Format(time.RFC3339) 160 + } 161 + return session.Save(r, w) 162 + } 163 + 45 164 // generateClientAssertion builds a private_key_jwt for token endpoint auth using ES256. 46 165 // Claims: 47 166 // ··· 416 535 } 417 536 418 537 did, ok := session.Values["did"].(string) 419 - if !ok { 538 + if !ok || did == "" { 420 539 return nil 421 540 } 422 541 423 - sess, ok := o.GetSession(did) 424 - if !ok { 425 - return nil 542 + // Prefer in-memory session; otherwise, attempt to rehydrate from cookie 543 + if sess, ok := o.GetSession(did); ok { 544 + return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 426 545 } 427 - 428 - return &User{ 429 - Handle: sess.Handle, 430 - Did: sess.Did, 431 - Pds: sess.PdsUrl, 546 + if sess, ok := o.GetSessionFromCookie(r); ok { 547 + // Cache it in memory for future lookups 548 + o.SaveSession(sess.Did, sess) 549 + return &User{Handle: sess.Handle, Did: sess.Did, Pds: sess.PdsUrl} 432 550 } 551 + return &User{Did: did} 433 552 } 434 553 435 554 type User struct {
+3 -3
server/templates/about.html
··· 21 21 <main class="container prose"> 22 22 <p>Tap is a proof-of-concept editor (in other words, a toy) for creating screenplay files in the <a href="https://fountain.io" target="_blank" rel="noreferrer">Fountain</a> format. I wrote it as an exercise to learn how <a href="https://atproto.com" target="_blank" rel="noreferrer">AT Protocol</a> works. Someday, I might add support for other Markdown-based document types.</p> 23 23 <ul> 24 - <li>It does not post screenplays to your Bluesky timeline. It stores them in an AT Protocol collection (<code>lol.tapapp.tap.doc</code>) in your Bluesky profile on their personal data server (PDS). Someday, Tap might support personal AT Proto PDSes.</li> 24 + <li>It does not post screenplays to your Bluesky timeline. It stores them in an AT Protocol collection (<code>lol.tapapp.tap.doc</code>) in your Bluesky profile on their personal data server (PDS). Someday, Tap will support non-Bluesky AT Protocol PDSes.</li> 25 25 <li><strong>It is not intended for production use.</strong> If you use Tap or store screenplays in Tap, you do so at your own risk.</li> 26 26 <li>It stores data unencrypted, and the data is publicly accessible on the Internet (Bluesky doesn't support private collections).</li> 27 27 <li>It uses <a href="https://docs.bsky.app/blog/oauth-atproto" target="_blank" rel="noreferrer">OAuth</a> for authentication with Bluesky.</li> 28 28 <li>It is designed for large screens. It is not optimized for mobile.</li> 29 - <li>The editor may become sluggish if you are running a writing assistant browser extension like Grammarly or ProWritingAid.</li> 29 + <li>The editor may become sluggish if you are running a writing extension like Grammarly or ProWritingAid.</li> 30 30 <li>If you want a sample Fountain script to test with, you can download one from <a href="https://fountain.io/_downloads/Big-Fish.fountain" target="_blank" rel="noreferrer">the Fountain website</a>.</li> 31 31 </ul> 32 32 <h2>Source code</h2> 33 - <p>Tap is open source under the <a href="https://choosealicense.com/licenses/mit/" target="_blank" rel="noreferrer">MIT license</a>. You can find the code on <a href="https://codeberg.org/limeleaf/tap-editor" target="_blank" rel="noreferrer">Codeberg</a>.</p> 33 + <p>Tap is open-sourced under the <a href="https://choosealicense.com/licenses/mit/" target="_blank" rel="noreferrer">MIT license</a>. You can find the code on <a href="https://tangled.sh/@limeleaf.coop/tap-app" target="_blank" rel="noreferrer">tangled</a>.</p> 34 34 </main> 35 35 36 36 <footer class="container footer">
+1 -1
web/components/tap-toolbar.ts
··· 123 123 <button id="btn-save">Save Now</button> 124 124 </div>` 125 125 : `<div class="auth auth-login"> 126 - <input id="inp-handle" type="text" placeholder="bsky handle" /> 126 + <input id="inp-handle" type="text" placeholder="Bluesky handle" /> 127 127 <button id="btn-login">Login</button> 128 128 </div>`; 129 129 const outlineHidden = this.hasAttribute('outline-hidden');