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 did := sessData.AccountDID.String()
99
100 // default to true, so users don't have to onboard again
101 isTangledUser, err := db.IsTangledUser(o.Db, did)
102 if err != nil {
103 isTangledUser = true
104 }
105
106 isNewUser := !isTangledUser
107 if isNewUser {
108 if ob, _ := db.GetOnboarding(o.Db, did); ob == nil {
109 if err := db.UpsertOnboarding(o.Db, &models.Onboarding{
110 Did: did,
111 Step: models.OnboardingStepProfile,
112 Status: models.OnboardingInProgress,
113 }); err != nil {
114 o.Logger.Error("failed to seed onboarding record", "did", did, "err", err)
115 }
116 }
117 }
118
119 o.ensureProfileRecord(sessData.AccountDID, sessData.SessionID)
120
121 go o.addToDefaultKnot(sessData.AccountDID)
122 go o.addToDefaultSpindle(sessData.AccountDID.String())
123 go o.autoClaimTnglShDomain(sessData.AccountDID.String())
124
125 if !o.Config.Core.Dev {
126 err = o.Posthog.Enqueue(posthog.Capture{
127 DistinctId: sessData.AccountDID.String(),
128 Event: "signin",
129 })
130 if err != nil {
131 o.Logger.Error("failed to enqueue posthog event", "err", err)
132 }
133 }
134
135 if redirectURL == "" {
136 redirectURL = "/"
137 }
138
139 if o.isAccountDeactivated(sessData) {
140 redirectURL = "/settings/profile"
141 } else if isNewUser {
142 redirectURL = "/welcome"
143 }
144
145 http.Redirect(w, r, redirectURL, http.StatusFound)
146}
147
148func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool {
149 pdsClient := &xrpc.Client{
150 Host: sessData.HostURL,
151 Client: &http.Client{Timeout: 5 * time.Second},
152 }
153
154 _, err := comatproto.RepoDescribeRepo(
155 context.Background(),
156 pdsClient,
157 sessData.AccountDID.String(),
158 )
159 if err == nil {
160 return false
161 }
162
163 var xrpcErr *xrpc.Error
164 var xrpcBody *xrpc.XRPCError
165 return errors.As(err, &xrpcErr) &&
166 errors.As(xrpcErr.Wrapped, &xrpcBody) &&
167 xrpcBody.ErrStr == "RepoDeactivated"
168}
169
170func (o *OAuth) addToDefaultSpindle(did string) {
171 l := o.Logger.With("subject", did)
172
173 // use the tangled.sh app password to get an accessJwt
174 // and create an sh.tangled.spindle.member record with that
175 spindleMembers, err := db.GetSpindleMembers(
176 o.Db,
177 orm.FilterEq("instance", "spindle.tangled.sh"),
178 orm.FilterEq("subject", did),
179 )
180 if err != nil {
181 l.Error("failed to get spindle members", "err", err)
182 return
183 }
184
185 if len(spindleMembers) != 0 {
186 l.Warn("already a member of the default spindle")
187 return
188 }
189
190 l.Debug("adding to default spindle")
191 session, err := o.getAppPasswordSession()
192 if err != nil {
193 l.Error("failed to create session", "err", err)
194 return
195 }
196
197 record := tangled.SpindleMember{
198 LexiconTypeID: tangled.SpindleMemberNSID,
199 Subject: did,
200 Instance: consts.DefaultSpindle,
201 CreatedAt: time.Now().Format(time.RFC3339),
202 }
203
204 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
205 l.Error("failed to add to default spindle", "err", err)
206 return
207 }
208
209 l.Debug("successfully added to default spindle", "did", did)
210}
211
212type onboardAction int
213
214const (
215 onboardViaAdminAPI onboardAction = iota
216 onboardViaRecord
217 onboardBlockedMissingSecret
218 onboardBlockedSecretSet
219)
220
221type defaultKnotState struct {
222 native bool
223 adminSecretSet bool
224}
225
226func onboardActionFor(s defaultKnotState) onboardAction {
227 switch {
228 case s.native && s.adminSecretSet:
229 return onboardViaAdminAPI
230 case s.native:
231 return onboardBlockedMissingSecret
232 case s.adminSecretSet:
233 return onboardBlockedSecretSet
234 default:
235 return onboardViaRecord
236 }
237}
238
239func (o *OAuth) addToDefaultKnot(did syntax.DID) {
240 l := o.Logger.With("subject", did)
241
242 ctx := context.Background()
243
244 if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) {
245 l.Warn("already a member of the default knot")
246 return
247 }
248
249 native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL)
250
251 switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) {
252 case onboardViaAdminAPI:
253 if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil {
254 l.Error("failed to add to default knot via admin api", "err", err)
255 return
256 }
257 o.Acl.InvalidateMembers(o.Config.Knot.Default)
258 l.Debug("successfully added to default knot via admin api")
259
260 case onboardBlockedMissingSecret:
261 l.Error("cannot add to default knot: knot admin secret not configured")
262
263 case onboardBlockedSecretSet:
264 l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured")
265
266 case onboardViaRecord:
267 l.Debug("adding to default knot")
268 session, err := o.getAppPasswordSession()
269 if err != nil {
270 l.Error("failed to create session", "err", err)
271 return
272 }
273
274 record := tangled.KnotMember{
275 LexiconTypeID: tangled.KnotMemberNSID,
276 Subject: did.String(),
277 Domain: o.Config.Knot.Default,
278 CreatedAt: time.Now().Format(time.RFC3339),
279 }
280
281 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
282 l.Error("failed to add to default knot", "err", err)
283 return
284 }
285
286 if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil {
287 l.Error("failed to set up enforcer rules", "err", err)
288 return
289 }
290
291 l.Debug("successfully added to default knot")
292 }
293}
294
295func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error {
296 ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout)
297 defer cancel()
298
299 scheme := "https://"
300 if o.Config.Core.Dev {
301 scheme = "http://"
302 }
303 endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost)
304
305 body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()})
306 if err != nil {
307 return err
308 }
309
310 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
311 if err != nil {
312 return err
313 }
314 req.Header.Set("Content-Type", "application/json")
315 req.SetBasicAuth("admin", o.Config.Knot.AdminSecret)
316
317 resp, err := http.DefaultClient.Do(req)
318 if err != nil {
319 return err
320 }
321 defer resp.Body.Close()
322
323 if resp.StatusCode != http.StatusOK {
324 msg, _ := io.ReadAll(resp.Body)
325 return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg))
326 }
327
328 return nil
329}
330
331// create a AppPasswordSession using apppasswords
332type AppPasswordSession struct {
333 AccessJwt string `json:"accessJwt"`
334 RefreshJwt string `json:"refreshJwt"`
335 PdsEndpoint string
336 Did string
337 Logger *slog.Logger
338 ExpiresAt time.Time
339}
340
341func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) {
342 if appPassword == "" {
343 return nil, fmt.Errorf("no app password configured")
344 }
345
346 resolved, err := res.ResolveIdent(context.Background(), did)
347 if err != nil {
348 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
349 }
350
351 pdsEndpoint := resolved.PDSEndpoint()
352 if pdsEndpoint == "" {
353 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
354 }
355
356 sessionPayload := map[string]string{
357 "identifier": did,
358 "password": appPassword,
359 }
360 sessionBytes, err := json.Marshal(sessionPayload)
361 if err != nil {
362 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
363 }
364
365 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
366 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
367 if err != nil {
368 return nil, fmt.Errorf("failed to create session request: %v", err)
369 }
370 sessionReq.Header.Set("Content-Type", "application/json")
371
372 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header)
373
374 client := &http.Client{Timeout: 30 * time.Second}
375 sessionResp, err := client.Do(sessionReq)
376 if err != nil {
377 return nil, fmt.Errorf("failed to create session: %v", err)
378 }
379 defer sessionResp.Body.Close()
380
381 if sessionResp.StatusCode != http.StatusOK {
382 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
383 }
384
385 var session AppPasswordSession
386 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
387 return nil, fmt.Errorf("failed to decode session response: %v", err)
388 }
389
390 session.PdsEndpoint = pdsEndpoint
391 session.Did = did
392 session.Logger = logger
393 session.ExpiresAt = time.Now().Add(115 * time.Minute)
394
395 return &session, nil
396}
397
398func (s *AppPasswordSession) RefreshSession() error {
399 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession"
400 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil)
401 if err != nil {
402 return fmt.Errorf("failed to create refresh request: %w", err)
403 }
404
405 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt)
406
407 s.Logger.Debug("refreshing app password session", "url", refreshURL)
408
409 client := &http.Client{Timeout: 30 * time.Second}
410 resp, err := client.Do(req)
411 if err != nil {
412 return fmt.Errorf("failed to refresh session: %w", err)
413 }
414 defer resp.Body.Close()
415
416 if resp.StatusCode != http.StatusOK {
417 var errorResponse map[string]any
418 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
419 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
420 }
421 errorBytes, _ := json.Marshal(errorResponse)
422 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes))
423 }
424
425 var refreshResponse struct {
426 AccessJwt string `json:"accessJwt"`
427 RefreshJwt string `json:"refreshJwt"`
428 }
429 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil {
430 return fmt.Errorf("failed to decode refresh response: %w", err)
431 }
432
433 s.AccessJwt = refreshResponse.AccessJwt
434 s.RefreshJwt = refreshResponse.RefreshJwt
435 // Set new expiry time with 5 minute buffer
436 s.ExpiresAt = time.Now().Add(115 * time.Minute)
437
438 s.Logger.Debug("successfully refreshed app password session")
439 return nil
440}
441
442func (s *AppPasswordSession) IsValid() bool {
443 return time.Now().Before(s.ExpiresAt)
444}
445
446func (s *AppPasswordSession) putRecord(record any, collection string) error {
447 if !s.IsValid() {
448 s.Logger.Debug("access token expired, refreshing session")
449 if err := s.RefreshSession(); err != nil {
450 return fmt.Errorf("failed to refresh session: %w", err)
451 }
452 s.Logger.Debug("session refreshed")
453 }
454
455 recordBytes, err := json.Marshal(record)
456 if err != nil {
457 return fmt.Errorf("failed to marshal knot member record: %w", err)
458 }
459
460 payload := map[string]any{
461 "repo": s.Did,
462 "collection": collection,
463 "rkey": tid.TID(),
464 "record": json.RawMessage(recordBytes),
465 }
466
467 payloadBytes, err := json.Marshal(payload)
468 if err != nil {
469 return fmt.Errorf("failed to marshal request payload: %w", err)
470 }
471
472 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
473 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
474 if err != nil {
475 return fmt.Errorf("failed to create HTTP request: %w", err)
476 }
477
478 req.Header.Set("Content-Type", "application/json")
479 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
480
481 s.Logger.Debug("putting record", "url", url, "collection", collection)
482
483 client := &http.Client{Timeout: 30 * time.Second}
484 resp, err := client.Do(req)
485 if err != nil {
486 return fmt.Errorf("failed to add user to default service: %w", err)
487 }
488 defer resp.Body.Close()
489
490 if resp.StatusCode != http.StatusOK {
491 var errorResponse map[string]any
492 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
493 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
494 }
495 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse)
496 }
497
498 return nil
499}
500
501// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so,
502// ensures their corresponding sites domain is claimed. This is idempotent —
503// ClaimDomain is a no-op if the claim already exists.
504func (o *OAuth) autoClaimTnglShDomain(did string) {
505 l := o.Logger.With("did", did)
506
507 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://")
508 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
509
510 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
511 if err != nil {
512 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err)
513 return
514 }
515
516 handle := resolved.Handle.String()
517 if !strings.HasSuffix(handle, "."+pdsDomain) {
518 return
519 }
520
521 if err := db.ClaimDomain(o.Db, did, handle); err != nil {
522 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err)
523 } else {
524 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle)
525 }
526}
527
528// ensureProfileRecord writes an empty profile record to the user's PDS if they
529// don't already have one, serves as a marker record for tangled uers
530//
531// also helps inform onboarding state
532func (o *OAuth) ensureProfileRecord(did syntax.DID, sessionId string) {
533 ctx := context.Background()
534 l := o.Logger.With("did", did)
535
536 didStr := did.String()
537 if profile, err := db.GetProfile(o.Db, didStr); err != nil {
538 l.Error("ensureProfileRecord: failed to read profile from db", "err", err)
539 return
540 } else if profile != nil {
541 // already has a profile record, leave it untouched
542 return
543 }
544
545 session, err := o.resumeSession(ctx, did, sessionId)
546 if err != nil {
547 l.Error("ensureProfileRecord: failed to resume session", "err", err)
548 return
549 }
550 client := session.APIClient()
551
552 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
553 Collection: tangled.ActorProfileNSID,
554 Repo: didStr,
555 Rkey: "self",
556 Record: &lexutil.LexiconTypeDecoder{
557 Val: &tangled.ActorProfile{},
558 },
559 })
560 if err != nil {
561 l.Error("ensureProfileRecord: failed to write profile record", "err", err)
562 return
563 }
564
565 // mirror to the local db so the appview reflects the record immediately
566 if err := db.UpsertProfile(o.Db, &models.Profile{Did: didStr}); err != nil {
567 l.Error("ensureProfileRecord: failed to upsert profile in db", "err", err)
568 }
569
570 l.Info("ensureProfileRecord: created empty profile record")
571}
572
573// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed.
574func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) {
575 o.appPasswordSessionMu.Lock()
576 defer o.appPasswordSessionMu.Unlock()
577
578 if o.appPasswordSession != nil {
579 return o.appPasswordSession, nil
580 }
581
582 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger)
583 if err != nil {
584 return nil, err
585 }
586
587 o.appPasswordSession = session
588 return session, nil
589}