This repository has no description
1package oauth
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "strings"
13 "time"
14
15 comatproto "github.com/bluesky-social/indigo/api/atproto"
16 "github.com/bluesky-social/indigo/atproto/auth/oauth"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 lexutil "github.com/bluesky-social/indigo/lex/util"
19 xrpc "github.com/bluesky-social/indigo/xrpc"
20 "github.com/go-chi/chi/v5"
21 "github.com/posthog/posthog-go"
22 "tangled.org/core/api/tangled"
23 "tangled.org/core/appview/db"
24 "tangled.org/core/appview/knotcompat"
25 "tangled.org/core/appview/models"
26 "tangled.org/core/consts"
27 "tangled.org/core/idresolver"
28 "tangled.org/core/orm"
29 "tangled.org/core/tid"
30)
31
32const knotAdminTimeout = 30 * time.Second
33
34func (o *OAuth) Router() http.Handler {
35 r := chi.NewRouter()
36
37 r.Get("/oauth/client-metadata.json", o.clientMetadata)
38 r.Get("/oauth/jwks.json", o.jwks)
39 r.Get("/oauth/callback", o.callback)
40 return r
41}
42
43func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
44 doc := o.ClientApp.Config.ClientMetadata()
45 doc.JWKSURI = &o.JwksUri
46 doc.ClientName = &o.ClientName
47 doc.ClientURI = &o.ClientUri
48 doc.Scope = doc.Scope + " identity:handle"
49
50 w.Header().Set("Content-Type", "application/json")
51 if err := json.NewEncoder(w).Encode(doc); err != nil {
52 http.Error(w, err.Error(), http.StatusInternalServerError)
53 return
54 }
55}
56
57func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
58 w.Header().Set("Content-Type", "application/json")
59 body := o.ClientApp.Config.PublicJWKS()
60 if err := json.NewEncoder(w).Encode(body); err != nil {
61 http.Error(w, err.Error(), http.StatusInternalServerError)
62 return
63 }
64}
65
66func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
67 ctx := r.Context()
68 l := o.Logger.With("query", r.URL.Query())
69
70 redirectURL := o.GetAuthReturn(r)
71 _ = o.ClearAuthReturn(w, r)
72
73 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
74 if err != nil {
75 var callbackErr *oauth.AuthRequestCallbackError
76 if errors.As(err, &callbackErr) {
77 l.Debug("callback error", "err", callbackErr)
78 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
79 return
80 }
81 l.Error("failed to process callback", "err", err)
82 http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
83 return
84 }
85
86 if err := o.SaveSession(w, r, sessData); err != nil {
87 l.Error("failed to save session", "data", sessData, "err", err)
88 errorCode := "session"
89 if errors.Is(err, ErrMaxAccountsReached) {
90 errorCode = "max_accounts"
91 }
92 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound)
93 return
94 }
95
96 o.Logger.Debug("session saved successfully")
97
98 go o.addToDefaultKnot(sessData.AccountDID)
99 go o.addToDefaultSpindle(sessData.AccountDID.String())
100 go o.ensureTangledProfile(sessData)
101 go o.autoClaimTnglShDomain(sessData.AccountDID.String())
102
103 if !o.Config.Core.Dev {
104 err = o.Posthog.Enqueue(posthog.Capture{
105 DistinctId: sessData.AccountDID.String(),
106 Event: "signin",
107 })
108 if err != nil {
109 o.Logger.Error("failed to enqueue posthog event", "err", err)
110 }
111 }
112
113 if redirectURL == "" {
114 redirectURL = "/"
115 }
116
117 if o.isAccountDeactivated(sessData) {
118 redirectURL = "/settings/profile"
119 }
120
121 http.Redirect(w, r, redirectURL, http.StatusFound)
122}
123
124func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool {
125 pdsClient := &xrpc.Client{
126 Host: sessData.HostURL,
127 Client: &http.Client{Timeout: 5 * time.Second},
128 }
129
130 _, err := comatproto.RepoDescribeRepo(
131 context.Background(),
132 pdsClient,
133 sessData.AccountDID.String(),
134 )
135 if err == nil {
136 return false
137 }
138
139 var xrpcErr *xrpc.Error
140 var xrpcBody *xrpc.XRPCError
141 return errors.As(err, &xrpcErr) &&
142 errors.As(xrpcErr.Wrapped, &xrpcBody) &&
143 xrpcBody.ErrStr == "RepoDeactivated"
144}
145
146func (o *OAuth) addToDefaultSpindle(did string) {
147 l := o.Logger.With("subject", did)
148
149 // use the tangled.sh app password to get an accessJwt
150 // and create an sh.tangled.spindle.member record with that
151 spindleMembers, err := db.GetSpindleMembers(
152 o.Db,
153 orm.FilterEq("instance", "spindle.tangled.sh"),
154 orm.FilterEq("subject", did),
155 )
156 if err != nil {
157 l.Error("failed to get spindle members", "err", err)
158 return
159 }
160
161 if len(spindleMembers) != 0 {
162 l.Warn("already a member of the default spindle")
163 return
164 }
165
166 l.Debug("adding to default spindle")
167 session, err := o.getAppPasswordSession()
168 if err != nil {
169 l.Error("failed to create session", "err", err)
170 return
171 }
172
173 record := tangled.SpindleMember{
174 LexiconTypeID: tangled.SpindleMemberNSID,
175 Subject: did,
176 Instance: consts.DefaultSpindle,
177 CreatedAt: time.Now().Format(time.RFC3339),
178 }
179
180 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
181 l.Error("failed to add to default spindle", "err", err)
182 return
183 }
184
185 l.Debug("successfully added to default spindle", "did", did)
186}
187
188type onboardAction int
189
190const (
191 onboardViaAdminAPI onboardAction = iota
192 onboardViaRecord
193 onboardBlockedMissingSecret
194 onboardBlockedSecretSet
195)
196
197type defaultKnotState struct {
198 native bool
199 adminSecretSet bool
200}
201
202func onboardActionFor(s defaultKnotState) onboardAction {
203 switch {
204 case s.native && s.adminSecretSet:
205 return onboardViaAdminAPI
206 case s.native:
207 return onboardBlockedMissingSecret
208 case s.adminSecretSet:
209 return onboardBlockedSecretSet
210 default:
211 return onboardViaRecord
212 }
213}
214
215func (o *OAuth) addToDefaultKnot(did syntax.DID) {
216 l := o.Logger.With("subject", did)
217
218 ctx := context.Background()
219
220 if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) {
221 l.Warn("already a member of the default knot")
222 return
223 }
224
225 native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL)
226
227 switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) {
228 case onboardViaAdminAPI:
229 if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil {
230 l.Error("failed to add to default knot via admin api", "err", err)
231 return
232 }
233 o.Acl.InvalidateMembers(o.Config.Knot.Default)
234 l.Debug("successfully added to default knot via admin api")
235
236 case onboardBlockedMissingSecret:
237 l.Error("cannot add to default knot: knot admin secret not configured")
238
239 case onboardBlockedSecretSet:
240 l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured")
241
242 case onboardViaRecord:
243 l.Debug("adding to default knot")
244 session, err := o.getAppPasswordSession()
245 if err != nil {
246 l.Error("failed to create session", "err", err)
247 return
248 }
249
250 record := tangled.KnotMember{
251 LexiconTypeID: tangled.KnotMemberNSID,
252 Subject: did.String(),
253 Domain: o.Config.Knot.Default,
254 CreatedAt: time.Now().Format(time.RFC3339),
255 }
256
257 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
258 l.Error("failed to add to default knot", "err", err)
259 return
260 }
261
262 if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil {
263 l.Error("failed to set up enforcer rules", "err", err)
264 return
265 }
266
267 l.Debug("successfully added to default knot")
268 }
269}
270
271func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error {
272 ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout)
273 defer cancel()
274
275 scheme := "https://"
276 if o.Config.Core.Dev {
277 scheme = "http://"
278 }
279 endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost)
280
281 body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()})
282 if err != nil {
283 return err
284 }
285
286 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
287 if err != nil {
288 return err
289 }
290 req.Header.Set("Content-Type", "application/json")
291 req.SetBasicAuth("admin", o.Config.Knot.AdminSecret)
292
293 resp, err := http.DefaultClient.Do(req)
294 if err != nil {
295 return err
296 }
297 defer resp.Body.Close()
298
299 if resp.StatusCode != http.StatusOK {
300 msg, _ := io.ReadAll(resp.Body)
301 return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg))
302 }
303
304 return nil
305}
306
307func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) {
308 ctx := context.Background()
309 did := sessData.AccountDID.String()
310 l := o.Logger.With("did", did)
311
312 profile, _ := db.GetProfile(o.Db, did)
313 if profile != nil {
314 l.Debug("profile already exists in DB")
315 return
316 }
317
318 l.Debug("creating empty Tangled profile")
319
320 sess, err := o.resumeSession(ctx, sessData.AccountDID, sessData.SessionID)
321 if err != nil {
322 l.Error("failed to resume session for profile creation", "err", err)
323 return
324 }
325 client := sess.APIClient()
326
327 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
328 Collection: tangled.ActorProfileNSID,
329 Repo: did,
330 Rkey: "self",
331 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}},
332 })
333
334 if err != nil {
335 l.Error("failed to create empty profile on PDS", "err", err)
336 return
337 }
338
339 emptyProfile := &models.Profile{Did: did}
340 if err := db.UpsertProfile(o.Db, emptyProfile); err != nil {
341 l.Error("failed to create empty profile in DB", "err", err)
342 return
343 }
344
345 l.Debug("successfully created empty Tangled profile on PDS and DB")
346}
347
348// create a AppPasswordSession using apppasswords
349type AppPasswordSession struct {
350 AccessJwt string `json:"accessJwt"`
351 RefreshJwt string `json:"refreshJwt"`
352 PdsEndpoint string
353 Did string
354 Logger *slog.Logger
355 ExpiresAt time.Time
356}
357
358func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) {
359 if appPassword == "" {
360 return nil, fmt.Errorf("no app password configured")
361 }
362
363 resolved, err := res.ResolveIdent(context.Background(), did)
364 if err != nil {
365 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
366 }
367
368 pdsEndpoint := resolved.PDSEndpoint()
369 if pdsEndpoint == "" {
370 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
371 }
372
373 sessionPayload := map[string]string{
374 "identifier": did,
375 "password": appPassword,
376 }
377 sessionBytes, err := json.Marshal(sessionPayload)
378 if err != nil {
379 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
380 }
381
382 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
383 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
384 if err != nil {
385 return nil, fmt.Errorf("failed to create session request: %v", err)
386 }
387 sessionReq.Header.Set("Content-Type", "application/json")
388
389 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header)
390
391 client := &http.Client{Timeout: 30 * time.Second}
392 sessionResp, err := client.Do(sessionReq)
393 if err != nil {
394 return nil, fmt.Errorf("failed to create session: %v", err)
395 }
396 defer sessionResp.Body.Close()
397
398 if sessionResp.StatusCode != http.StatusOK {
399 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
400 }
401
402 var session AppPasswordSession
403 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
404 return nil, fmt.Errorf("failed to decode session response: %v", err)
405 }
406
407 session.PdsEndpoint = pdsEndpoint
408 session.Did = did
409 session.Logger = logger
410 session.ExpiresAt = time.Now().Add(115 * time.Minute)
411
412 return &session, nil
413}
414
415func (s *AppPasswordSession) RefreshSession() error {
416 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession"
417 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil)
418 if err != nil {
419 return fmt.Errorf("failed to create refresh request: %w", err)
420 }
421
422 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt)
423
424 s.Logger.Debug("refreshing app password session", "url", refreshURL)
425
426 client := &http.Client{Timeout: 30 * time.Second}
427 resp, err := client.Do(req)
428 if err != nil {
429 return fmt.Errorf("failed to refresh session: %w", err)
430 }
431 defer resp.Body.Close()
432
433 if resp.StatusCode != http.StatusOK {
434 var errorResponse map[string]any
435 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
436 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
437 }
438 errorBytes, _ := json.Marshal(errorResponse)
439 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes))
440 }
441
442 var refreshResponse struct {
443 AccessJwt string `json:"accessJwt"`
444 RefreshJwt string `json:"refreshJwt"`
445 }
446 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil {
447 return fmt.Errorf("failed to decode refresh response: %w", err)
448 }
449
450 s.AccessJwt = refreshResponse.AccessJwt
451 s.RefreshJwt = refreshResponse.RefreshJwt
452 // Set new expiry time with 5 minute buffer
453 s.ExpiresAt = time.Now().Add(115 * time.Minute)
454
455 s.Logger.Debug("successfully refreshed app password session")
456 return nil
457}
458
459func (s *AppPasswordSession) IsValid() bool {
460 return time.Now().Before(s.ExpiresAt)
461}
462
463func (s *AppPasswordSession) putRecord(record any, collection string) error {
464 if !s.IsValid() {
465 s.Logger.Debug("access token expired, refreshing session")
466 if err := s.RefreshSession(); err != nil {
467 return fmt.Errorf("failed to refresh session: %w", err)
468 }
469 s.Logger.Debug("session refreshed")
470 }
471
472 recordBytes, err := json.Marshal(record)
473 if err != nil {
474 return fmt.Errorf("failed to marshal knot member record: %w", err)
475 }
476
477 payload := map[string]any{
478 "repo": s.Did,
479 "collection": collection,
480 "rkey": tid.TID(),
481 "record": json.RawMessage(recordBytes),
482 }
483
484 payloadBytes, err := json.Marshal(payload)
485 if err != nil {
486 return fmt.Errorf("failed to marshal request payload: %w", err)
487 }
488
489 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
490 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
491 if err != nil {
492 return fmt.Errorf("failed to create HTTP request: %w", err)
493 }
494
495 req.Header.Set("Content-Type", "application/json")
496 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
497
498 s.Logger.Debug("putting record", "url", url, "collection", collection)
499
500 client := &http.Client{Timeout: 30 * time.Second}
501 resp, err := client.Do(req)
502 if err != nil {
503 return fmt.Errorf("failed to add user to default service: %w", err)
504 }
505 defer resp.Body.Close()
506
507 if resp.StatusCode != http.StatusOK {
508 var errorResponse map[string]any
509 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
510 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
511 }
512 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse)
513 }
514
515 return nil
516}
517
518// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so,
519// ensures their corresponding sites domain is claimed. This is idempotent —
520// ClaimDomain is a no-op if the claim already exists.
521func (o *OAuth) autoClaimTnglShDomain(did string) {
522 l := o.Logger.With("did", did)
523
524 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://")
525 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
526
527 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
528 if err != nil {
529 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err)
530 return
531 }
532
533 handle := resolved.Handle.String()
534 if !strings.HasSuffix(handle, "."+pdsDomain) {
535 return
536 }
537
538 if err := db.ClaimDomain(o.Db, did, handle); err != nil {
539 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err)
540 } else {
541 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle)
542 }
543}
544
545// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed.
546func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) {
547 o.appPasswordSessionMu.Lock()
548 defer o.appPasswordSessionMu.Unlock()
549
550 if o.appPasswordSession != nil {
551 return o.appPasswordSession, nil
552 }
553
554 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger)
555 if err != nil {
556 return nil, err
557 }
558
559 o.appPasswordSession = session
560 return session, nil
561}