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