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