This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / oauth / handler.go
17 kB 589 lines
1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log/slog" 11 "net/http" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 lexutil "github.com/bluesky-social/indigo/lex/util" 19 xrpc "github.com/bluesky-social/indigo/xrpc" 20 "github.com/go-chi/chi/v5" 21 "github.com/posthog/posthog-go" 22 "tangled.org/core/api/tangled" 23 "tangled.org/core/appview/db" 24 "tangled.org/core/appview/knotcompat" 25 "tangled.org/core/appview/models" 26 "tangled.org/core/consts" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/orm" 29 "tangled.org/core/tid" 30) 31 32const knotAdminTimeout = 30 * time.Second 33 34func (o *OAuth) Router() http.Handler { 35 r := chi.NewRouter() 36 37 r.Get("/oauth/client-metadata.json", o.clientMetadata) 38 r.Get("/oauth/jwks.json", o.jwks) 39 r.Get("/oauth/callback", o.callback) 40 return r 41} 42 43func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 44 doc := o.ClientApp.Config.ClientMetadata() 45 doc.JWKSURI = &o.JwksUri 46 doc.ClientName = &o.ClientName 47 doc.ClientURI = &o.ClientUri 48 doc.Scope = doc.Scope + " identity:handle" 49 50 w.Header().Set("Content-Type", "application/json") 51 if err := json.NewEncoder(w).Encode(doc); err != nil { 52 http.Error(w, err.Error(), http.StatusInternalServerError) 53 return 54 } 55} 56 57func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 58 w.Header().Set("Content-Type", "application/json") 59 body := o.ClientApp.Config.PublicJWKS() 60 if err := json.NewEncoder(w).Encode(body); err != nil { 61 http.Error(w, err.Error(), http.StatusInternalServerError) 62 return 63 } 64} 65 66func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 67 ctx := r.Context() 68 l := o.Logger.With("query", r.URL.Query()) 69 70 redirectURL := o.GetAuthReturn(r) 71 _ = o.ClearAuthReturn(w, r) 72 73 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 74 if err != nil { 75 var callbackErr *oauth.AuthRequestCallbackError 76 if errors.As(err, &callbackErr) { 77 l.Debug("callback error", "err", callbackErr) 78 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 79 return 80 } 81 l.Error("failed to process callback", "err", err) 82 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 83 return 84 } 85 86 if err := o.SaveSession(w, r, sessData); err != nil { 87 l.Error("failed to save session", "data", sessData, "err", err) 88 errorCode := "session" 89 if errors.Is(err, ErrMaxAccountsReached) { 90 errorCode = "max_accounts" 91 } 92 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 93 return 94 } 95 96 o.Logger.Debug("session saved successfully") 97 98 did := sessData.AccountDID.String() 99 100 // default to true, so users don't have to onboard again 101 isTangledUser, err := db.IsTangledUser(o.Db, did) 102 if err != nil { 103 isTangledUser = true 104 } 105 106 isNewUser := !isTangledUser 107 if isNewUser { 108 if ob, _ := db.GetOnboarding(o.Db, did); ob == nil { 109 if err := db.UpsertOnboarding(o.Db, &models.Onboarding{ 110 Did: did, 111 Step: models.OnboardingStepProfile, 112 Status: models.OnboardingInProgress, 113 }); err != nil { 114 o.Logger.Error("failed to seed onboarding record", "did", did, "err", err) 115 } 116 } 117 } 118 119 o.ensureProfileRecord(sessData.AccountDID, sessData.SessionID) 120 121 go o.addToDefaultKnot(sessData.AccountDID) 122 go o.addToDefaultSpindle(sessData.AccountDID.String()) 123 go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 124 125 if !o.Config.Core.Dev { 126 err = o.Posthog.Enqueue(posthog.Capture{ 127 DistinctId: sessData.AccountDID.String(), 128 Event: "signin", 129 }) 130 if err != nil { 131 o.Logger.Error("failed to enqueue posthog event", "err", err) 132 } 133 } 134 135 if redirectURL == "" { 136 redirectURL = "/" 137 } 138 139 if o.isAccountDeactivated(sessData) { 140 redirectURL = "/settings/profile" 141 } else if isNewUser { 142 redirectURL = "/welcome" 143 } 144 145 http.Redirect(w, r, redirectURL, http.StatusFound) 146} 147 148func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { 149 pdsClient := &xrpc.Client{ 150 Host: sessData.HostURL, 151 Client: &http.Client{Timeout: 5 * time.Second}, 152 } 153 154 _, err := comatproto.RepoDescribeRepo( 155 context.Background(), 156 pdsClient, 157 sessData.AccountDID.String(), 158 ) 159 if err == nil { 160 return false 161 } 162 163 var xrpcErr *xrpc.Error 164 var xrpcBody *xrpc.XRPCError 165 return errors.As(err, &xrpcErr) && 166 errors.As(xrpcErr.Wrapped, &xrpcBody) && 167 xrpcBody.ErrStr == "RepoDeactivated" 168} 169 170func (o *OAuth) addToDefaultSpindle(did string) { 171 l := o.Logger.With("subject", did) 172 173 // use the tangled.sh app password to get an accessJwt 174 // and create an sh.tangled.spindle.member record with that 175 spindleMembers, err := db.GetSpindleMembers( 176 o.Db, 177 orm.FilterEq("instance", "spindle.tangled.sh"), 178 orm.FilterEq("subject", did), 179 ) 180 if err != nil { 181 l.Error("failed to get spindle members", "err", err) 182 return 183 } 184 185 if len(spindleMembers) != 0 { 186 l.Warn("already a member of the default spindle") 187 return 188 } 189 190 l.Debug("adding to default spindle") 191 session, err := o.getAppPasswordSession() 192 if err != nil { 193 l.Error("failed to create session", "err", err) 194 return 195 } 196 197 record := tangled.SpindleMember{ 198 LexiconTypeID: tangled.SpindleMemberNSID, 199 Subject: did, 200 Instance: consts.DefaultSpindle, 201 CreatedAt: time.Now().Format(time.RFC3339), 202 } 203 204 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 205 l.Error("failed to add to default spindle", "err", err) 206 return 207 } 208 209 l.Debug("successfully added to default spindle", "did", did) 210} 211 212type onboardAction int 213 214const ( 215 onboardViaAdminAPI onboardAction = iota 216 onboardViaRecord 217 onboardBlockedMissingSecret 218 onboardBlockedSecretSet 219) 220 221type defaultKnotState struct { 222 native bool 223 adminSecretSet bool 224} 225 226func onboardActionFor(s defaultKnotState) onboardAction { 227 switch { 228 case s.native && s.adminSecretSet: 229 return onboardViaAdminAPI 230 case s.native: 231 return onboardBlockedMissingSecret 232 case s.adminSecretSet: 233 return onboardBlockedSecretSet 234 default: 235 return onboardViaRecord 236 } 237} 238 239func (o *OAuth) addToDefaultKnot(did syntax.DID) { 240 l := o.Logger.With("subject", did) 241 242 ctx := context.Background() 243 244 if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) { 245 l.Warn("already a member of the default knot") 246 return 247 } 248 249 native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL) 250 251 switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) { 252 case onboardViaAdminAPI: 253 if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil { 254 l.Error("failed to add to default knot via admin api", "err", err) 255 return 256 } 257 o.Acl.InvalidateMembers(o.Config.Knot.Default) 258 l.Debug("successfully added to default knot via admin api") 259 260 case onboardBlockedMissingSecret: 261 l.Error("cannot add to default knot: knot admin secret not configured") 262 263 case onboardBlockedSecretSet: 264 l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured") 265 266 case onboardViaRecord: 267 l.Debug("adding to default knot") 268 session, err := o.getAppPasswordSession() 269 if err != nil { 270 l.Error("failed to create session", "err", err) 271 return 272 } 273 274 record := tangled.KnotMember{ 275 LexiconTypeID: tangled.KnotMemberNSID, 276 Subject: did.String(), 277 Domain: o.Config.Knot.Default, 278 CreatedAt: time.Now().Format(time.RFC3339), 279 } 280 281 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 282 l.Error("failed to add to default knot", "err", err) 283 return 284 } 285 286 if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil { 287 l.Error("failed to set up enforcer rules", "err", err) 288 return 289 } 290 291 l.Debug("successfully added to default knot") 292 } 293} 294 295func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error { 296 ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout) 297 defer cancel() 298 299 scheme := "https://" 300 if o.Config.Core.Dev { 301 scheme = "http://" 302 } 303 endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost) 304 305 body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()}) 306 if err != nil { 307 return err 308 } 309 310 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) 311 if err != nil { 312 return err 313 } 314 req.Header.Set("Content-Type", "application/json") 315 req.SetBasicAuth("admin", o.Config.Knot.AdminSecret) 316 317 resp, err := http.DefaultClient.Do(req) 318 if err != nil { 319 return err 320 } 321 defer resp.Body.Close() 322 323 if resp.StatusCode != http.StatusOK { 324 msg, _ := io.ReadAll(resp.Body) 325 return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg)) 326 } 327 328 return nil 329} 330 331// create a AppPasswordSession using apppasswords 332type AppPasswordSession struct { 333 AccessJwt string `json:"accessJwt"` 334 RefreshJwt string `json:"refreshJwt"` 335 PdsEndpoint string 336 Did string 337 Logger *slog.Logger 338 ExpiresAt time.Time 339} 340 341func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) { 342 if appPassword == "" { 343 return nil, fmt.Errorf("no app password configured") 344 } 345 346 resolved, err := res.ResolveIdent(context.Background(), did) 347 if err != nil { 348 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 349 } 350 351 pdsEndpoint := resolved.PDSEndpoint() 352 if pdsEndpoint == "" { 353 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 354 } 355 356 sessionPayload := map[string]string{ 357 "identifier": did, 358 "password": appPassword, 359 } 360 sessionBytes, err := json.Marshal(sessionPayload) 361 if err != nil { 362 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 363 } 364 365 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 366 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 367 if err != nil { 368 return nil, fmt.Errorf("failed to create session request: %v", err) 369 } 370 sessionReq.Header.Set("Content-Type", "application/json") 371 372 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 373 374 client := &http.Client{Timeout: 30 * time.Second} 375 sessionResp, err := client.Do(sessionReq) 376 if err != nil { 377 return nil, fmt.Errorf("failed to create session: %v", err) 378 } 379 defer sessionResp.Body.Close() 380 381 if sessionResp.StatusCode != http.StatusOK { 382 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 383 } 384 385 var session AppPasswordSession 386 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 387 return nil, fmt.Errorf("failed to decode session response: %v", err) 388 } 389 390 session.PdsEndpoint = pdsEndpoint 391 session.Did = did 392 session.Logger = logger 393 session.ExpiresAt = time.Now().Add(115 * time.Minute) 394 395 return &session, nil 396} 397 398func (s *AppPasswordSession) RefreshSession() error { 399 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession" 400 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil) 401 if err != nil { 402 return fmt.Errorf("failed to create refresh request: %w", err) 403 } 404 405 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt) 406 407 s.Logger.Debug("refreshing app password session", "url", refreshURL) 408 409 client := &http.Client{Timeout: 30 * time.Second} 410 resp, err := client.Do(req) 411 if err != nil { 412 return fmt.Errorf("failed to refresh session: %w", err) 413 } 414 defer resp.Body.Close() 415 416 if resp.StatusCode != http.StatusOK { 417 var errorResponse map[string]any 418 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 419 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 420 } 421 errorBytes, _ := json.Marshal(errorResponse) 422 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes)) 423 } 424 425 var refreshResponse struct { 426 AccessJwt string `json:"accessJwt"` 427 RefreshJwt string `json:"refreshJwt"` 428 } 429 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil { 430 return fmt.Errorf("failed to decode refresh response: %w", err) 431 } 432 433 s.AccessJwt = refreshResponse.AccessJwt 434 s.RefreshJwt = refreshResponse.RefreshJwt 435 // Set new expiry time with 5 minute buffer 436 s.ExpiresAt = time.Now().Add(115 * time.Minute) 437 438 s.Logger.Debug("successfully refreshed app password session") 439 return nil 440} 441 442func (s *AppPasswordSession) IsValid() bool { 443 return time.Now().Before(s.ExpiresAt) 444} 445 446func (s *AppPasswordSession) putRecord(record any, collection string) error { 447 if !s.IsValid() { 448 s.Logger.Debug("access token expired, refreshing session") 449 if err := s.RefreshSession(); err != nil { 450 return fmt.Errorf("failed to refresh session: %w", err) 451 } 452 s.Logger.Debug("session refreshed") 453 } 454 455 recordBytes, err := json.Marshal(record) 456 if err != nil { 457 return fmt.Errorf("failed to marshal knot member record: %w", err) 458 } 459 460 payload := map[string]any{ 461 "repo": s.Did, 462 "collection": collection, 463 "rkey": tid.TID(), 464 "record": json.RawMessage(recordBytes), 465 } 466 467 payloadBytes, err := json.Marshal(payload) 468 if err != nil { 469 return fmt.Errorf("failed to marshal request payload: %w", err) 470 } 471 472 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 473 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 474 if err != nil { 475 return fmt.Errorf("failed to create HTTP request: %w", err) 476 } 477 478 req.Header.Set("Content-Type", "application/json") 479 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 480 481 s.Logger.Debug("putting record", "url", url, "collection", collection) 482 483 client := &http.Client{Timeout: 30 * time.Second} 484 resp, err := client.Do(req) 485 if err != nil { 486 return fmt.Errorf("failed to add user to default service: %w", err) 487 } 488 defer resp.Body.Close() 489 490 if resp.StatusCode != http.StatusOK { 491 var errorResponse map[string]any 492 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 493 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 494 } 495 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse) 496 } 497 498 return nil 499} 500 501// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so, 502// ensures their corresponding sites domain is claimed. This is idempotent — 503// ClaimDomain is a no-op if the claim already exists. 504func (o *OAuth) autoClaimTnglShDomain(did string) { 505 l := o.Logger.With("did", did) 506 507 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://") 508 pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 509 510 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 511 if err != nil { 512 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err) 513 return 514 } 515 516 handle := resolved.Handle.String() 517 if !strings.HasSuffix(handle, "."+pdsDomain) { 518 return 519 } 520 521 if err := db.ClaimDomain(o.Db, did, handle); err != nil { 522 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err) 523 } else { 524 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle) 525 } 526} 527 528// ensureProfileRecord writes an empty profile record to the user's PDS if they 529// don't already have one, serves as a marker record for tangled uers 530// 531// also helps inform onboarding state 532func (o *OAuth) ensureProfileRecord(did syntax.DID, sessionId string) { 533 ctx := context.Background() 534 l := o.Logger.With("did", did) 535 536 didStr := did.String() 537 if profile, err := db.GetProfile(o.Db, didStr); err != nil { 538 l.Error("ensureProfileRecord: failed to read profile from db", "err", err) 539 return 540 } else if profile != nil { 541 // already has a profile record, leave it untouched 542 return 543 } 544 545 session, err := o.resumeSession(ctx, did, sessionId) 546 if err != nil { 547 l.Error("ensureProfileRecord: failed to resume session", "err", err) 548 return 549 } 550 client := session.APIClient() 551 552 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 553 Collection: tangled.ActorProfileNSID, 554 Repo: didStr, 555 Rkey: "self", 556 Record: &lexutil.LexiconTypeDecoder{ 557 Val: &tangled.ActorProfile{}, 558 }, 559 }) 560 if err != nil { 561 l.Error("ensureProfileRecord: failed to write profile record", "err", err) 562 return 563 } 564 565 // mirror to the local db so the appview reflects the record immediately 566 if err := db.UpsertProfile(o.Db, &models.Profile{Did: didStr}); err != nil { 567 l.Error("ensureProfileRecord: failed to upsert profile in db", "err", err) 568 } 569 570 l.Info("ensureProfileRecord: created empty profile record") 571} 572 573// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed. 574func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) { 575 o.appPasswordSessionMu.Lock() 576 defer o.appPasswordSessionMu.Unlock() 577 578 if o.appPasswordSession != nil { 579 return o.appPasswordSession, nil 580 } 581 582 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger) 583 if err != nil { 584 return nil, err 585 } 586 587 o.appPasswordSession = session 588 return session, nil 589}