Monorepo for Tangled
1package oauth
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "slices"
11 "time"
12
13 comatproto "github.com/bluesky-social/indigo/api/atproto"
14 "github.com/bluesky-social/indigo/atproto/auth/oauth"
15 lexutil "github.com/bluesky-social/indigo/lex/util"
16 "github.com/go-chi/chi/v5"
17 "github.com/posthog/posthog-go"
18 "tangled.org/core/api/tangled"
19 "tangled.org/core/appview/db"
20 "tangled.org/core/appview/models"
21 "tangled.org/core/consts"
22 "tangled.org/core/idresolver"
23 "tangled.org/core/orm"
24 "tangled.org/core/tid"
25)
26
27func (o *OAuth) Router() http.Handler {
28 r := chi.NewRouter()
29
30 r.Get("/oauth/client-metadata.json", o.clientMetadata)
31 r.Get("/oauth/jwks.json", o.jwks)
32 r.Get("/oauth/callback", o.callback)
33 return r
34}
35
36func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
37 doc := o.ClientApp.Config.ClientMetadata()
38 doc.JWKSURI = &o.JwksUri
39 doc.ClientName = &o.ClientName
40 doc.ClientURI = &o.ClientUri
41
42 w.Header().Set("Content-Type", "application/json")
43 if err := json.NewEncoder(w).Encode(doc); err != nil {
44 http.Error(w, err.Error(), http.StatusInternalServerError)
45 return
46 }
47}
48
49func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
50 w.Header().Set("Content-Type", "application/json")
51 body := o.ClientApp.Config.PublicJWKS()
52 if err := json.NewEncoder(w).Encode(body); err != nil {
53 http.Error(w, err.Error(), http.StatusInternalServerError)
54 return
55 }
56}
57
58func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
59 ctx := r.Context()
60 l := o.Logger.With("query", r.URL.Query())
61
62 authReturn := o.GetAuthReturn(r)
63 _ = o.ClearAuthReturn(w, r)
64
65 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
66 if err != nil {
67 var callbackErr *oauth.AuthRequestCallbackError
68 if errors.As(err, &callbackErr) {
69 l.Debug("callback error", "err", callbackErr)
70 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound)
71 return
72 }
73 l.Error("failed to process callback", "err", err)
74 http.Redirect(w, r, "/login?error=oauth", http.StatusFound)
75 return
76 }
77
78 if err := o.SaveSession(w, r, sessData); err != nil {
79 l.Error("failed to save session", "data", sessData, "err", err)
80 errorCode := "session"
81 if errors.Is(err, ErrMaxAccountsReached) {
82 errorCode = "max_accounts"
83 }
84 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound)
85 return
86 }
87
88 o.Logger.Debug("session saved successfully")
89
90 go o.addToDefaultKnot(sessData.AccountDID.String())
91 go o.addToDefaultSpindle(sessData.AccountDID.String())
92 go o.ensureTangledProfile(sessData)
93
94 if !o.Config.Core.Dev {
95 err = o.Posthog.Enqueue(posthog.Capture{
96 DistinctId: sessData.AccountDID.String(),
97 Event: "signin",
98 })
99 if err != nil {
100 o.Logger.Error("failed to enqueue posthog event", "err", err)
101 }
102 }
103
104 redirectURL := "/"
105 if authReturn.ReturnURL != "" {
106 redirectURL = authReturn.ReturnURL
107 }
108
109 http.Redirect(w, r, redirectURL, http.StatusFound)
110}
111
112func (o *OAuth) addToDefaultSpindle(did string) {
113 l := o.Logger.With("subject", did)
114
115 // use the tangled.sh app password to get an accessJwt
116 // and create an sh.tangled.spindle.member record with that
117 spindleMembers, err := db.GetSpindleMembers(
118 o.Db,
119 orm.FilterEq("instance", "spindle.tangled.sh"),
120 orm.FilterEq("subject", did),
121 )
122 if err != nil {
123 l.Error("failed to get spindle members", "err", err)
124 return
125 }
126
127 if len(spindleMembers) != 0 {
128 l.Warn("already a member of the default spindle")
129 return
130 }
131
132 l.Debug("adding to default spindle")
133 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid)
134 if err != nil {
135 l.Error("failed to create session", "err", err)
136 return
137 }
138
139 record := tangled.SpindleMember{
140 LexiconTypeID: tangled.SpindleMemberNSID,
141 Subject: did,
142 Instance: consts.DefaultSpindle,
143 CreatedAt: time.Now().Format(time.RFC3339),
144 }
145
146 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
147 l.Error("failed to add to default spindle", "err", err)
148 return
149 }
150
151 l.Debug("successfully added to default spindle", "did", did)
152}
153
154func (o *OAuth) addToDefaultKnot(did string) {
155 l := o.Logger.With("subject", did)
156
157 // use the tangled.sh app password to get an accessJwt
158 // and create an sh.tangled.spindle.member record with that
159
160 allKnots, err := o.Enforcer.GetKnotsForUser(did)
161 if err != nil {
162 l.Error("failed to get knot members for did", "err", err)
163 return
164 }
165
166 if slices.Contains(allKnots, consts.DefaultKnot) {
167 l.Warn("already a member of the default knot")
168 return
169 }
170
171 l.Debug("adding to default knot")
172 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid)
173 if err != nil {
174 l.Error("failed to create session", "err", err)
175 return
176 }
177
178 record := tangled.KnotMember{
179 LexiconTypeID: tangled.KnotMemberNSID,
180 Subject: did,
181 Domain: consts.DefaultKnot,
182 CreatedAt: time.Now().Format(time.RFC3339),
183 }
184
185 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
186 l.Error("failed to add to default knot", "err", err)
187 return
188 }
189
190 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
191 l.Error("failed to set up enforcer rules", "err", err)
192 return
193 }
194
195 l.Debug("successfully addeds to default Knot")
196}
197
198func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) {
199 ctx := context.Background()
200 did := sessData.AccountDID.String()
201 l := o.Logger.With("did", did)
202
203 profile, _ := db.GetProfile(o.Db, did)
204 if profile != nil {
205 l.Debug("profile already exists in DB")
206 return
207 }
208
209 l.Debug("creating empty Tangled profile")
210
211 sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
212 if err != nil {
213 l.Error("failed to resume session for profile creation", "err", err)
214 return
215 }
216 client := sess.APIClient()
217
218 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
219 Collection: tangled.ActorProfileNSID,
220 Repo: did,
221 Rkey: "self",
222 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}},
223 })
224
225 if err != nil {
226 l.Error("failed to create empty profile on PDS", "err", err)
227 return
228 }
229
230 tx, err := o.Db.BeginTx(ctx, nil)
231 if err != nil {
232 l.Error("failed to start transaction", "err", err)
233 return
234 }
235
236 emptyProfile := &models.Profile{Did: did}
237 if err := db.UpsertProfile(tx, emptyProfile); err != nil {
238 l.Error("failed to create empty profile in DB", "err", err)
239 return
240 }
241
242 l.Debug("successfully created empty Tangled profile on PDS and DB")
243}
244
245// create a AppPasswordSession using apppasswords
246type AppPasswordSession struct {
247 AccessJwt string `json:"accessJwt"`
248 PdsEndpoint string
249 Did string
250}
251
252func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string) (*AppPasswordSession, error) {
253 if appPassword == "" {
254 return nil, fmt.Errorf("no app password configured")
255 }
256
257 resolved, err := res.ResolveIdent(context.Background(), did)
258 if err != nil {
259 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
260 }
261
262 pdsEndpoint := resolved.PDSEndpoint()
263 if pdsEndpoint == "" {
264 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
265 }
266
267 sessionPayload := map[string]string{
268 "identifier": did,
269 "password": appPassword,
270 }
271 sessionBytes, err := json.Marshal(sessionPayload)
272 if err != nil {
273 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
274 }
275
276 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
277 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
278 if err != nil {
279 return nil, fmt.Errorf("failed to create session request: %v", err)
280 }
281 sessionReq.Header.Set("Content-Type", "application/json")
282
283 client := &http.Client{Timeout: 30 * time.Second}
284 sessionResp, err := client.Do(sessionReq)
285 if err != nil {
286 return nil, fmt.Errorf("failed to create session: %v", err)
287 }
288 defer sessionResp.Body.Close()
289
290 if sessionResp.StatusCode != http.StatusOK {
291 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
292 }
293
294 var session AppPasswordSession
295 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
296 return nil, fmt.Errorf("failed to decode session response: %v", err)
297 }
298
299 session.PdsEndpoint = pdsEndpoint
300 session.Did = did
301
302 return &session, nil
303}
304
305func (s *AppPasswordSession) putRecord(record any, collection string) error {
306 recordBytes, err := json.Marshal(record)
307 if err != nil {
308 return fmt.Errorf("failed to marshal knot member record: %w", err)
309 }
310
311 payload := map[string]any{
312 "repo": s.Did,
313 "collection": collection,
314 "rkey": tid.TID(),
315 "record": json.RawMessage(recordBytes),
316 }
317
318 payloadBytes, err := json.Marshal(payload)
319 if err != nil {
320 return fmt.Errorf("failed to marshal request payload: %w", err)
321 }
322
323 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
324 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
325 if err != nil {
326 return fmt.Errorf("failed to create HTTP request: %w", err)
327 }
328
329 req.Header.Set("Content-Type", "application/json")
330 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
331
332 client := &http.Client{Timeout: 30 * time.Second}
333 resp, err := client.Do(req)
334 if err != nil {
335 return fmt.Errorf("failed to add user to default service: %w", err)
336 }
337 defer resp.Body.Close()
338
339 if resp.StatusCode != http.StatusOK {
340 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
341 }
342
343 return nil
344}