+4
-4
.gitignore
+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
+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
+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
+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
+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');