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