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