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