Monorepo for Tangled
tangled.org
1package oauth
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "slices"
11 "time"
12
13 "github.com/bluesky-social/indigo/atproto/auth/oauth"
14 "github.com/go-chi/chi/v5"
15 "github.com/lestrrat-go/jwx/v2/jwk"
16 "github.com/posthog/posthog-go"
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/db"
19 "tangled.org/core/consts"
20 "tangled.org/core/tid"
21)
22
23func (o *OAuth) Router() http.Handler {
24 r := chi.NewRouter()
25
26 r.Get("/oauth/client-metadata.json", o.clientMetadata)
27 r.Get("/oauth/jwks.json", o.jwks)
28 r.Get("/oauth/callback", o.callback)
29 return r
30}
31
32func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
33 doc := o.ClientApp.Config.ClientMetadata()
34 doc.JWKSURI = &o.JwksUri
35
36 w.Header().Set("Content-Type", "application/json")
37 if err := json.NewEncoder(w).Encode(doc); err != nil {
38 http.Error(w, err.Error(), http.StatusInternalServerError)
39 return
40 }
41}
42
43func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
44 jwks := o.Config.OAuth.Jwks
45 pubKey, err := pubKeyFromJwk(jwks)
46 if err != nil {
47 o.Logger.Error("error parsing public key", "err", err)
48 http.Error(w, err.Error(), http.StatusInternalServerError)
49 return
50 }
51
52 response := map[string]any{
53 "keys": []jwk.Key{pubKey},
54 }
55
56 w.Header().Set("Content-Type", "application/json")
57 w.WriteHeader(http.StatusOK)
58 json.NewEncoder(w).Encode(response)
59}
60
61func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
62 ctx := r.Context()
63 l := o.Logger.With("query", r.URL.Query())
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 http.Redirect(w, r, "/login?error=session", http.StatusFound)
81 return
82 }
83
84 o.Logger.Debug("session saved successfully")
85 go o.addToDefaultKnot(sessData.AccountDID.String())
86 go o.addToDefaultSpindle(sessData.AccountDID.String())
87
88 if !o.Config.Core.Dev {
89 err = o.Posthog.Enqueue(posthog.Capture{
90 DistinctId: sessData.AccountDID.String(),
91 Event: "signin",
92 })
93 if err != nil {
94 o.Logger.Error("failed to enqueue posthog event", "err", err)
95 }
96 }
97
98 http.Redirect(w, r, "/", http.StatusFound)
99}
100
101func (o *OAuth) addToDefaultSpindle(did string) {
102 l := o.Logger.With("subject", did)
103
104 // use the tangled.sh app password to get an accessJwt
105 // and create an sh.tangled.spindle.member record with that
106 spindleMembers, err := db.GetSpindleMembers(
107 o.Db,
108 db.FilterEq("instance", "spindle.tangled.sh"),
109 db.FilterEq("subject", did),
110 )
111 if err != nil {
112 l.Error("failed to get spindle members", "err", err)
113 return
114 }
115
116 if len(spindleMembers) != 0 {
117 l.Warn("already a member of the default spindle")
118 return
119 }
120
121 l.Debug("adding to default spindle")
122 session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid)
123 if err != nil {
124 l.Error("failed to create session", "err", err)
125 return
126 }
127
128 record := tangled.SpindleMember{
129 LexiconTypeID: "sh.tangled.spindle.member",
130 Subject: did,
131 Instance: consts.DefaultSpindle,
132 CreatedAt: time.Now().Format(time.RFC3339),
133 }
134
135 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
136 l.Error("failed to add to default spindle", "err", err)
137 return
138 }
139
140 l.Debug("successfully added to default spindle", "did", did)
141}
142
143func (o *OAuth) addToDefaultKnot(did string) {
144 l := o.Logger.With("subject", did)
145
146 // use the tangled.sh app password to get an accessJwt
147 // and create an sh.tangled.spindle.member record with that
148
149 allKnots, err := o.Enforcer.GetKnotsForUser(did)
150 if err != nil {
151 l.Error("failed to get knot members for did", "err", err)
152 return
153 }
154
155 if slices.Contains(allKnots, consts.DefaultKnot) {
156 l.Warn("already a member of the default knot")
157 return
158 }
159
160 l.Debug("addings to default knot")
161 session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid)
162 if err != nil {
163 l.Error("failed to create session", "err", err)
164 return
165 }
166
167 record := tangled.KnotMember{
168 LexiconTypeID: "sh.tangled.knot.member",
169 Subject: did,
170 Domain: consts.DefaultKnot,
171 CreatedAt: time.Now().Format(time.RFC3339),
172 }
173
174 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
175 l.Error("failed to add to default knot", "err", err)
176 return
177 }
178
179 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
180 l.Error("failed to set up enforcer rules", "err", err)
181 return
182 }
183
184 l.Debug("successfully addeds to default Knot")
185}
186
187// create a session using apppasswords
188type session struct {
189 AccessJwt string `json:"accessJwt"`
190 PdsEndpoint string
191 Did string
192}
193
194func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) {
195 if appPassword == "" {
196 return nil, fmt.Errorf("no app password configured, skipping member addition")
197 }
198
199 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did)
200 if err != nil {
201 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
202 }
203
204 pdsEndpoint := resolved.PDSEndpoint()
205 if pdsEndpoint == "" {
206 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
207 }
208
209 sessionPayload := map[string]string{
210 "identifier": did,
211 "password": appPassword,
212 }
213 sessionBytes, err := json.Marshal(sessionPayload)
214 if err != nil {
215 return nil, fmt.Errorf("failed to marshal session payload: %v", err)
216 }
217
218 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
219 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
220 if err != nil {
221 return nil, fmt.Errorf("failed to create session request: %v", err)
222 }
223 sessionReq.Header.Set("Content-Type", "application/json")
224
225 client := &http.Client{Timeout: 30 * time.Second}
226 sessionResp, err := client.Do(sessionReq)
227 if err != nil {
228 return nil, fmt.Errorf("failed to create session: %v", err)
229 }
230 defer sessionResp.Body.Close()
231
232 if sessionResp.StatusCode != http.StatusOK {
233 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
234 }
235
236 var session session
237 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
238 return nil, fmt.Errorf("failed to decode session response: %v", err)
239 }
240
241 session.PdsEndpoint = pdsEndpoint
242 session.Did = did
243
244 return &session, nil
245}
246
247func (s *session) putRecord(record any, collection string) error {
248 recordBytes, err := json.Marshal(record)
249 if err != nil {
250 return fmt.Errorf("failed to marshal knot member record: %w", err)
251 }
252
253 payload := map[string]any{
254 "repo": s.Did,
255 "collection": collection,
256 "rkey": tid.TID(),
257 "record": json.RawMessage(recordBytes),
258 }
259
260 payloadBytes, err := json.Marshal(payload)
261 if err != nil {
262 return fmt.Errorf("failed to marshal request payload: %w", err)
263 }
264
265 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
266 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
267 if err != nil {
268 return fmt.Errorf("failed to create HTTP request: %w", err)
269 }
270
271 req.Header.Set("Content-Type", "application/json")
272 req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
273
274 client := &http.Client{Timeout: 30 * time.Second}
275 resp, err := client.Do(req)
276 if err != nil {
277 return fmt.Errorf("failed to add user to default service: %w", err)
278 }
279 defer resp.Body.Close()
280
281 if resp.StatusCode != http.StatusOK {
282 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
283 }
284
285 return nil
286}