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