Monorepo for Tangled
at master 387 lines 11 kB view raw
1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "log/slog" 10 "net/http" 11 "slices" 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/auth/oauth" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" 18 "github.com/posthog/posthog-go" 19 "tangled.org/core/api/tangled" 20 "tangled.org/core/appview/db" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/consts" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/orm" 25 "tangled.org/core/tid" 26) 27 28func (o *OAuth) Router() http.Handler { 29 r := chi.NewRouter() 30 31 r.Get("/oauth/client-metadata.json", o.clientMetadata) 32 r.Get("/oauth/jwks.json", o.jwks) 33 r.Get("/oauth/callback", o.callback) 34 return r 35} 36 37func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 38 doc := o.ClientApp.Config.ClientMetadata() 39 doc.JWKSURI = &o.JwksUri 40 doc.ClientName = &o.ClientName 41 doc.ClientURI = &o.ClientUri 42 43 w.Header().Set("Content-Type", "application/json") 44 if err := json.NewEncoder(w).Encode(doc); err != nil { 45 http.Error(w, err.Error(), http.StatusInternalServerError) 46 return 47 } 48} 49 50func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 51 w.Header().Set("Content-Type", "application/json") 52 body := o.ClientApp.Config.PublicJWKS() 53 if err := json.NewEncoder(w).Encode(body); err != nil { 54 http.Error(w, err.Error(), http.StatusInternalServerError) 55 return 56 } 57} 58 59func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 60 ctx := r.Context() 61 l := o.Logger.With("query", r.URL.Query()) 62 63 authReturn := o.GetAuthReturn(r) 64 _ = o.ClearAuthReturn(w, r) 65 66 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 67 if err != nil { 68 var callbackErr *oauth.AuthRequestCallbackError 69 if errors.As(err, &callbackErr) { 70 l.Debug("callback error", "err", callbackErr) 71 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 72 return 73 } 74 l.Error("failed to process callback", "err", err) 75 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 76 return 77 } 78 79 if err := o.SaveSession(w, r, sessData); err != nil { 80 l.Error("failed to save session", "data", sessData, "err", err) 81 errorCode := "session" 82 if errors.Is(err, ErrMaxAccountsReached) { 83 errorCode = "max_accounts" 84 } 85 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 86 return 87 } 88 89 o.Logger.Debug("session saved successfully") 90 91 go o.addToDefaultKnot(sessData.AccountDID.String()) 92 go o.addToDefaultSpindle(sessData.AccountDID.String()) 93 go o.ensureTangledProfile(sessData) 94 95 if !o.Config.Core.Dev { 96 err = o.Posthog.Enqueue(posthog.Capture{ 97 DistinctId: sessData.AccountDID.String(), 98 Event: "signin", 99 }) 100 if err != nil { 101 o.Logger.Error("failed to enqueue posthog event", "err", err) 102 } 103 } 104 105 redirectURL := "/" 106 if authReturn.ReturnURL != "" { 107 redirectURL = authReturn.ReturnURL 108 } 109 110 http.Redirect(w, r, redirectURL, http.StatusFound) 111} 112 113func (o *OAuth) addToDefaultSpindle(did string) { 114 l := o.Logger.With("subject", did) 115 116 // use the tangled.sh app password to get an accessJwt 117 // and create an sh.tangled.spindle.member record with that 118 spindleMembers, err := db.GetSpindleMembers( 119 o.Db, 120 orm.FilterEq("instance", "spindle.tangled.sh"), 121 orm.FilterEq("subject", did), 122 ) 123 if err != nil { 124 l.Error("failed to get spindle members", "err", err) 125 return 126 } 127 128 if len(spindleMembers) != 0 { 129 l.Warn("already a member of the default spindle") 130 return 131 } 132 133 l.Debug("adding to default spindle") 134 session, err := o.getAppPasswordSession() 135 if err != nil { 136 l.Error("failed to create session", "err", err) 137 return 138 } 139 140 record := tangled.SpindleMember{ 141 LexiconTypeID: tangled.SpindleMemberNSID, 142 Subject: did, 143 Instance: consts.DefaultSpindle, 144 CreatedAt: time.Now().Format(time.RFC3339), 145 } 146 147 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 148 o.invalidateAppPasswordSession() 149 l.Error("failed to add to default spindle", "err", err) 150 return 151 } 152 153 l.Debug("successfully added to default spindle", "did", did) 154} 155 156func (o *OAuth) addToDefaultKnot(did string) { 157 l := o.Logger.With("subject", did) 158 159 // use the tangled.sh app password to get an accessJwt 160 // and create an sh.tangled.spindle.member record with that 161 162 allKnots, err := o.Enforcer.GetKnotsForUser(did) 163 if err != nil { 164 l.Error("failed to get knot members for did", "err", err) 165 return 166 } 167 168 if slices.Contains(allKnots, consts.DefaultKnot) { 169 l.Warn("already a member of the default knot") 170 return 171 } 172 173 l.Debug("adding to default knot") 174 session, err := o.getAppPasswordSession() 175 if err != nil { 176 l.Error("failed to create session", "err", err) 177 return 178 } 179 180 record := tangled.KnotMember{ 181 LexiconTypeID: tangled.KnotMemberNSID, 182 Subject: did, 183 Domain: consts.DefaultKnot, 184 CreatedAt: time.Now().Format(time.RFC3339), 185 } 186 187 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 188 o.invalidateAppPasswordSession() 189 l.Error("failed to add to default knot", "err", err) 190 return 191 } 192 193 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 194 l.Error("failed to set up enforcer rules", "err", err) 195 return 196 } 197 198 l.Debug("successfully addeds to default Knot") 199} 200 201func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { 202 ctx := context.Background() 203 did := sessData.AccountDID.String() 204 l := o.Logger.With("did", did) 205 206 profile, _ := db.GetProfile(o.Db, did) 207 if profile != nil { 208 l.Debug("profile already exists in DB") 209 return 210 } 211 212 l.Debug("creating empty Tangled profile") 213 214 sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 215 if err != nil { 216 l.Error("failed to resume session for profile creation", "err", err) 217 return 218 } 219 client := sess.APIClient() 220 221 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 222 Collection: tangled.ActorProfileNSID, 223 Repo: did, 224 Rkey: "self", 225 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}}, 226 }) 227 228 if err != nil { 229 l.Error("failed to create empty profile on PDS", "err", err) 230 return 231 } 232 233 tx, err := o.Db.BeginTx(ctx, nil) 234 if err != nil { 235 l.Error("failed to start transaction", "err", err) 236 return 237 } 238 239 emptyProfile := &models.Profile{Did: did} 240 if err := db.UpsertProfile(tx, emptyProfile); err != nil { 241 l.Error("failed to create empty profile in DB", "err", err) 242 return 243 } 244 245 l.Debug("successfully created empty Tangled profile on PDS and DB") 246} 247 248// create a AppPasswordSession using apppasswords 249type AppPasswordSession struct { 250 AccessJwt string `json:"accessJwt"` 251 PdsEndpoint string 252 Did string 253 RateLimitBypass string 254 Logger *slog.Logger 255} 256 257func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did, rateLimitBypass string, logger *slog.Logger) (*AppPasswordSession, error) { 258 if appPassword == "" { 259 return nil, fmt.Errorf("no app password configured") 260 } 261 262 resolved, err := res.ResolveIdent(context.Background(), did) 263 if err != nil { 264 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 265 } 266 267 pdsEndpoint := resolved.PDSEndpoint() 268 if pdsEndpoint == "" { 269 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 270 } 271 272 sessionPayload := map[string]string{ 273 "identifier": did, 274 "password": appPassword, 275 } 276 sessionBytes, err := json.Marshal(sessionPayload) 277 if err != nil { 278 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 279 } 280 281 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 282 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 283 if err != nil { 284 return nil, fmt.Errorf("failed to create session request: %v", err) 285 } 286 sessionReq.Header.Set("Content-Type", "application/json") 287 if rateLimitBypass != "" { 288 sessionReq.Header.Set("x-ratelimit-bypass", rateLimitBypass) 289 } 290 291 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 292 293 client := &http.Client{Timeout: 30 * time.Second} 294 sessionResp, err := client.Do(sessionReq) 295 if err != nil { 296 return nil, fmt.Errorf("failed to create session: %v", err) 297 } 298 defer sessionResp.Body.Close() 299 300 if sessionResp.StatusCode != http.StatusOK { 301 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 302 } 303 304 var session AppPasswordSession 305 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 306 return nil, fmt.Errorf("failed to decode session response: %v", err) 307 } 308 309 session.PdsEndpoint = pdsEndpoint 310 session.Did = did 311 session.RateLimitBypass = rateLimitBypass 312 session.Logger = logger 313 314 return &session, nil 315} 316 317func (s *AppPasswordSession) putRecord(record any, collection string) error { 318 recordBytes, err := json.Marshal(record) 319 if err != nil { 320 return fmt.Errorf("failed to marshal knot member record: %w", err) 321 } 322 323 payload := map[string]any{ 324 "repo": s.Did, 325 "collection": collection, 326 "rkey": tid.TID(), 327 "record": json.RawMessage(recordBytes), 328 } 329 330 payloadBytes, err := json.Marshal(payload) 331 if err != nil { 332 return fmt.Errorf("failed to marshal request payload: %w", err) 333 } 334 335 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 336 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 337 if err != nil { 338 return fmt.Errorf("failed to create HTTP request: %w", err) 339 } 340 341 req.Header.Set("Content-Type", "application/json") 342 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 343 if s.RateLimitBypass != "" { 344 req.Header.Set("x-ratelimit-bypass", s.RateLimitBypass) 345 } 346 347 s.Logger.Debug("putting record", "url", url, "collection", collection, "headers", req.Header) 348 349 client := &http.Client{Timeout: 30 * time.Second} 350 resp, err := client.Do(req) 351 if err != nil { 352 return fmt.Errorf("failed to add user to default service: %w", err) 353 } 354 defer resp.Body.Close() 355 356 if resp.StatusCode != http.StatusOK { 357 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 358 } 359 360 return nil 361} 362 363// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed. 364func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) { 365 o.appPasswordSessionMu.Lock() 366 defer o.appPasswordSessionMu.Unlock() 367 368 if o.appPasswordSession != nil { 369 return o.appPasswordSession, nil 370 } 371 372 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Config.Core.RateLimitBypass, o.Logger) 373 if err != nil { 374 return nil, err 375 } 376 377 o.appPasswordSession = session 378 return session, nil 379} 380 381// invalidateAppPasswordSession clears the cached session so the next call to 382// getAppPasswordSession will create a fresh one. 383func (o *OAuth) invalidateAppPasswordSession() { 384 o.appPasswordSessionMu.Lock() 385 defer o.appPasswordSessionMu.Unlock() 386 o.appPasswordSession = nil 387}