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}