Monorepo for Tangled
1package oauth
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "slices"
12 "time"
13
14 comatproto "github.com/bluesky-social/indigo/api/atproto"
15 "github.com/bluesky-social/indigo/atproto/auth/oauth"
16 lexutil "github.com/bluesky-social/indigo/lex/util"
17 "github.com/go-chi/chi/v5"
18 "github.com/posthog/posthog-go"
19 "tangled.org/core/api/tangled"
20 "tangled.org/core/appview/db"
21 "tangled.org/core/appview/models"
22 "tangled.org/core/consts"
23 "tangled.org/core/idresolver"
24 "tangled.org/core/orm"
25 "tangled.org/core/tid"
26)
27
28func (o *OAuth) Router() http.Handler {
29 r := chi.NewRouter()
30
31 r.Get("/oauth/client-metadata.json", o.clientMetadata)
32 r.Get("/oauth/jwks.json", o.jwks)
33 r.Get("/oauth/callback", o.callback)
34 return r
35}
36
37func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
38 doc := o.ClientApp.Config.ClientMetadata()
39 doc.JWKSURI = &o.JwksUri
40 doc.ClientName = &o.ClientName
41 doc.ClientURI = &o.ClientUri
42
43 w.Header().Set("Content-Type", "application/json")
44 if err := json.NewEncoder(w).Encode(doc); err != nil {
45 http.Error(w, err.Error(), http.StatusInternalServerError)
46 return
47 }
48}
49
50func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
51 w.Header().Set("Content-Type", "application/json")
52 body := o.ClientApp.Config.PublicJWKS()
53 if err := json.NewEncoder(w).Encode(body); err != nil {
54 http.Error(w, err.Error(), http.StatusInternalServerError)
55 return
56 }
57}
58
59func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
60 ctx := r.Context()
61 l := o.Logger.With("query", r.URL.Query())
62
63 authReturn := o.GetAuthReturn(r)
64 _ = o.ClearAuthReturn(w, r)
65
66 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
67 if err != nil {
68 var callbackErr *oauth.AuthRequestCallbackError
69 if errors.As(err, &callbackErr) {
70 l.Debug("callback error", "err", callbackErr)
71 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
72 return
73 }
74 l.Error("failed to process callback", "err", err)
75 http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
76 return
77 }
78
79 if err := o.SaveSession(w, r, sessData); err != nil {
80 l.Error("failed to save session", "data", sessData, "err", err)
81 errorCode := "session"
82 if errors.Is(err, ErrMaxAccountsReached) {
83 errorCode = "max_accounts"
84 }
85 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound)
86 return
87 }
88
89 o.Logger.Debug("session saved successfully")
90
91 go o.addToDefaultKnot(sessData.AccountDID.String())
92 go o.addToDefaultSpindle(sessData.AccountDID.String())
93 go o.ensureTangledProfile(sessData)
94
95 if !o.Config.Core.Dev {
96 err = o.Posthog.Enqueue(posthog.Capture{
97 DistinctId: sessData.AccountDID.String(),
98 Event: "signin",
99 })
100 if err != nil {
101 o.Logger.Error("failed to enqueue posthog event", "err", err)
102 }
103 }
104
105 redirectURL := "/"
106 if authReturn.ReturnURL != "" {
107 redirectURL = authReturn.ReturnURL
108 }
109
110 http.Redirect(w, r, redirectURL, http.StatusFound)
111}
112
113func (o *OAuth) addToDefaultSpindle(did string) {
114 l := o.Logger.With("subject", did)
115
116 // use the tangled.sh app password to get an accessJwt
117 // and create an sh.tangled.spindle.member record with that
118 spindleMembers, err := db.GetSpindleMembers(
119 o.Db,
120 orm.FilterEq("instance", "spindle.tangled.sh"),
121 orm.FilterEq("subject", did),
122 )
123 if err != nil {
124 l.Error("failed to get spindle members", "err", err)
125 return
126 }
127
128 if len(spindleMembers) != 0 {
129 l.Warn("already a member of the default spindle")
130 return
131 }
132
133 l.Debug("adding to default spindle")
134 session, err := o.getAppPasswordSession()
135 if err != nil {
136 l.Error("failed to create session", "err", err)
137 return
138 }
139
140 record := tangled.SpindleMember{
141 LexiconTypeID: tangled.SpindleMemberNSID,
142 Subject: did,
143 Instance: consts.DefaultSpindle,
144 CreatedAt: time.Now().Format(time.RFC3339),
145 }
146
147 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
148 o.invalidateAppPasswordSession()
149 l.Error("failed to add to default spindle", "err", err)
150 return
151 }
152
153 l.Debug("successfully added to default spindle", "did", did)
154}
155
156func (o *OAuth) addToDefaultKnot(did string) {
157 l := o.Logger.With("subject", did)
158
159 // use the tangled.sh app password to get an accessJwt
160 // and create an sh.tangled.spindle.member record with that
161
162 allKnots, err := o.Enforcer.GetKnotsForUser(did)
163 if err != nil {
164 l.Error("failed to get knot members for did", "err", err)
165 return
166 }
167
168 if slices.Contains(allKnots, consts.DefaultKnot) {
169 l.Warn("already a member of the default knot")
170 return
171 }
172
173 l.Debug("adding to default knot")
174 session, err := o.getAppPasswordSession()
175 if err != nil {
176 l.Error("failed to create session", "err", err)
177 return
178 }
179
180 record := tangled.KnotMember{
181 LexiconTypeID: tangled.KnotMemberNSID,
182 Subject: did,
183 Domain: consts.DefaultKnot,
184 CreatedAt: time.Now().Format(time.RFC3339),
185 }
186
187 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
188 o.invalidateAppPasswordSession()
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 addeds 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 PdsEndpoint string
252 Did string
253 RateLimitBypass string
254 Logger *slog.Logger
255}
256
257func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did, rateLimitBypass string, logger *slog.Logger) (*AppPasswordSession, error) {
258 if appPassword == "" {
259 return nil, fmt.Errorf("no app password configured")
260 }
261
262 resolved, err := res.ResolveIdent(context.Background(), did)
263 if err != nil {
264 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
265 }
266
267 pdsEndpoint := resolved.PDSEndpoint()
268 if pdsEndpoint == "" {
269 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
270 }
271
272 sessionPayload := map[string]string{
273 "identifier": did,
274 "password": appPassword,
275 }
276 sessionBytes, err := json.Marshal(sessionPayload)
277 if err != nil {
278 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
279 }
280
281 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
282 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
283 if err != nil {
284 return nil, fmt.Errorf("failed to create session request: %v", err)
285 }
286 sessionReq.Header.Set("Content-Type", "application/json")
287 if rateLimitBypass != "" {
288 sessionReq.Header.Set("x-ratelimit-bypass", rateLimitBypass)
289 }
290
291 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header)
292
293 client := &http.Client{Timeout: 30 * time.Second}
294 sessionResp, err := client.Do(sessionReq)
295 if err != nil {
296 return nil, fmt.Errorf("failed to create session: %v", err)
297 }
298 defer sessionResp.Body.Close()
299
300 if sessionResp.StatusCode != http.StatusOK {
301 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
302 }
303
304 var session AppPasswordSession
305 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
306 return nil, fmt.Errorf("failed to decode session response: %v", err)
307 }
308
309 session.PdsEndpoint = pdsEndpoint
310 session.Did = did
311 session.RateLimitBypass = rateLimitBypass
312 session.Logger = logger
313
314 return &session, nil
315}
316
317func (s *AppPasswordSession) putRecord(record any, collection string) error {
318 recordBytes, err := json.Marshal(record)
319 if err != nil {
320 return fmt.Errorf("failed to marshal knot member record: %w", err)
321 }
322
323 payload := map[string]any{
324 "repo": s.Did,
325 "collection": collection,
326 "rkey": tid.TID(),
327 "record": json.RawMessage(recordBytes),
328 }
329
330 payloadBytes, err := json.Marshal(payload)
331 if err != nil {
332 return fmt.Errorf("failed to marshal request payload: %w", err)
333 }
334
335 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
336 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
337 if err != nil {
338 return fmt.Errorf("failed to create HTTP request: %w", err)
339 }
340
341 req.Header.Set("Content-Type", "application/json")
342 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
343 if s.RateLimitBypass != "" {
344 req.Header.Set("x-ratelimit-bypass", s.RateLimitBypass)
345 }
346
347 s.Logger.Debug("putting record", "url", url, "collection", collection, "headers", req.Header)
348
349 client := &http.Client{Timeout: 30 * time.Second}
350 resp, err := client.Do(req)
351 if err != nil {
352 return fmt.Errorf("failed to add user to default service: %w", err)
353 }
354 defer resp.Body.Close()
355
356 if resp.StatusCode != http.StatusOK {
357 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
358 }
359
360 return nil
361}
362
363// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed.
364func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) {
365 o.appPasswordSessionMu.Lock()
366 defer o.appPasswordSessionMu.Unlock()
367
368 if o.appPasswordSession != nil {
369 return o.appPasswordSession, nil
370 }
371
372 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Config.Core.RateLimitBypass, o.Logger)
373 if err != nil {
374 return nil, err
375 }
376
377 o.appPasswordSession = session
378 return session, nil
379}
380
381// invalidateAppPasswordSession clears the cached session so the next call to
382// getAppPasswordSession will create a fresh one.
383func (o *OAuth) invalidateAppPasswordSession() {
384 o.appPasswordSessionMu.Lock()
385 defer o.appPasswordSessionMu.Unlock()
386 o.appPasswordSession = nil
387}