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