Monorepo for Tangled
tangled.org
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 tx, err := o.Db.BeginTx(ctx, nil)
340 if err != nil {
341 l.Error("failed to start transaction", "err", err)
342 return
343 }
344
345 emptyProfile := &models.Profile{Did: did}
346 if err := db.UpsertProfile(tx, emptyProfile); err != nil {
347 l.Error("failed to create empty profile in DB", "err", err)
348 return
349 }
350
351 l.Debug("successfully created empty Tangled profile on PDS and DB")
352}
353
354// create a AppPasswordSession using apppasswords
355type AppPasswordSession struct {
356 AccessJwt string `json:"accessJwt"`
357 RefreshJwt string `json:"refreshJwt"`
358 PdsEndpoint string
359 Did string
360 Logger *slog.Logger
361 ExpiresAt time.Time
362}
363
364func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) {
365 if appPassword == "" {
366 return nil, fmt.Errorf("no app password configured")
367 }
368
369 resolved, err := res.ResolveIdent(context.Background(), did)
370 if err != nil {
371 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
372 }
373
374 pdsEndpoint := resolved.PDSEndpoint()
375 if pdsEndpoint == "" {
376 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
377 }
378
379 sessionPayload := map[string]string{
380 "identifier": did,
381 "password": appPassword,
382 }
383 sessionBytes, err := json.Marshal(sessionPayload)
384 if err != nil {
385 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
386 }
387
388 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
389 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
390 if err != nil {
391 return nil, fmt.Errorf("failed to create session request: %v", err)
392 }
393 sessionReq.Header.Set("Content-Type", "application/json")
394
395 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header)
396
397 client := &http.Client{Timeout: 30 * time.Second}
398 sessionResp, err := client.Do(sessionReq)
399 if err != nil {
400 return nil, fmt.Errorf("failed to create session: %v", err)
401 }
402 defer sessionResp.Body.Close()
403
404 if sessionResp.StatusCode != http.StatusOK {
405 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
406 }
407
408 var session AppPasswordSession
409 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
410 return nil, fmt.Errorf("failed to decode session response: %v", err)
411 }
412
413 session.PdsEndpoint = pdsEndpoint
414 session.Did = did
415 session.Logger = logger
416 session.ExpiresAt = time.Now().Add(115 * time.Minute)
417
418 return &session, nil
419}
420
421func (s *AppPasswordSession) RefreshSession() error {
422 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession"
423 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil)
424 if err != nil {
425 return fmt.Errorf("failed to create refresh request: %w", err)
426 }
427
428 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt)
429
430 s.Logger.Debug("refreshing app password session", "url", refreshURL)
431
432 client := &http.Client{Timeout: 30 * time.Second}
433 resp, err := client.Do(req)
434 if err != nil {
435 return fmt.Errorf("failed to refresh session: %w", err)
436 }
437 defer resp.Body.Close()
438
439 if resp.StatusCode != http.StatusOK {
440 var errorResponse map[string]any
441 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
442 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
443 }
444 errorBytes, _ := json.Marshal(errorResponse)
445 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes))
446 }
447
448 var refreshResponse struct {
449 AccessJwt string `json:"accessJwt"`
450 RefreshJwt string `json:"refreshJwt"`
451 }
452 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil {
453 return fmt.Errorf("failed to decode refresh response: %w", err)
454 }
455
456 s.AccessJwt = refreshResponse.AccessJwt
457 s.RefreshJwt = refreshResponse.RefreshJwt
458 // Set new expiry time with 5 minute buffer
459 s.ExpiresAt = time.Now().Add(115 * time.Minute)
460
461 s.Logger.Debug("successfully refreshed app password session")
462 return nil
463}
464
465func (s *AppPasswordSession) IsValid() bool {
466 return time.Now().Before(s.ExpiresAt)
467}
468
469func (s *AppPasswordSession) putRecord(record any, collection string) error {
470 if !s.IsValid() {
471 s.Logger.Debug("access token expired, refreshing session")
472 if err := s.RefreshSession(); err != nil {
473 return fmt.Errorf("failed to refresh session: %w", err)
474 }
475 s.Logger.Debug("session refreshed")
476 }
477
478 recordBytes, err := json.Marshal(record)
479 if err != nil {
480 return fmt.Errorf("failed to marshal knot member record: %w", err)
481 }
482
483 payload := map[string]any{
484 "repo": s.Did,
485 "collection": collection,
486 "rkey": tid.TID(),
487 "record": json.RawMessage(recordBytes),
488 }
489
490 payloadBytes, err := json.Marshal(payload)
491 if err != nil {
492 return fmt.Errorf("failed to marshal request payload: %w", err)
493 }
494
495 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
496 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
497 if err != nil {
498 return fmt.Errorf("failed to create HTTP request: %w", err)
499 }
500
501 req.Header.Set("Content-Type", "application/json")
502 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
503
504 s.Logger.Debug("putting record", "url", url, "collection", collection)
505
506 client := &http.Client{Timeout: 30 * time.Second}
507 resp, err := client.Do(req)
508 if err != nil {
509 return fmt.Errorf("failed to add user to default service: %w", err)
510 }
511 defer resp.Body.Close()
512
513 if resp.StatusCode != http.StatusOK {
514 var errorResponse map[string]any
515 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil {
516 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err)
517 }
518 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse)
519 }
520
521 return nil
522}
523
524// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so,
525// ensures their corresponding sites domain is claimed. This is idempotent —
526// ClaimDomain is a no-op if the claim already exists.
527func (o *OAuth) autoClaimTnglShDomain(did string) {
528 l := o.Logger.With("did", did)
529
530 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://")
531 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
532
533 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
534 if err != nil {
535 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err)
536 return
537 }
538
539 handle := resolved.Handle.String()
540 if !strings.HasSuffix(handle, "."+pdsDomain) {
541 return
542 }
543
544 if err := db.ClaimDomain(o.Db, did, handle); err != nil {
545 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err)
546 } else {
547 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle)
548 }
549}
550
551// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed.
552func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) {
553 o.appPasswordSessionMu.Lock()
554 defer o.appPasswordSessionMu.Unlock()
555
556 if o.appPasswordSession != nil {
557 return o.appPasswordSession, nil
558 }
559
560 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger)
561 if err != nil {
562 return nil, err
563 }
564
565 o.appPasswordSession = session
566 return session, nil
567}