// Package auth provides UserContext for managing authenticated user state // throughout request handling in the AppView. package auth import ( "context" "database/sql" "encoding/json" "fmt" "io" "log/slog" "net/http" "sync" "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "atcr.io/pkg/auth/oauth" ) // Auth method constants (duplicated from token package to avoid import cycle) const ( AuthMethodOAuth = "oauth" AuthMethodAppPassword = "app_password" ) // RequestAction represents the type of registry operation type RequestAction int const ( ActionUnknown RequestAction = iota ActionPull // GET/HEAD - reading from registry ActionPush // PUT/POST/DELETE - writing to registry ActionInspect // Metadata operations only ) func (a RequestAction) String() string { switch a { case ActionPull: return "pull" case ActionPush: return "push" case ActionInspect: return "inspect" default: return "unknown" } } // HoldPermissions describes what the user can do on a specific hold type HoldPermissions struct { HoldDID string // Hold being checked IsOwner bool // User is captain of this hold IsCrew bool // User is a crew member IsPublic bool // Hold allows public reads CanRead bool // Computed: can user read blobs? CanWrite bool // Computed: can user write blobs? CanAdmin bool // Computed: can user manage crew? Permissions []string // Raw permissions from crew record } // contextKey is unexported to prevent collisions type contextKey struct{} // userContextKey is the context key for UserContext var userContextKey = contextKey{} // userSetupCache tracks which users have had their profile/crew setup ensured var userSetupCache sync.Map // did -> time.Time // userSetupTTL is how long to cache user setup status (1 hour) const userSetupTTL = 1 * time.Hour // Dependencies bundles services needed by UserContext type Dependencies struct { Refresher *oauth.Refresher Authorizer HoldAuthorizer DefaultHoldDID string // AppView's default hold DID } // UserContext encapsulates authenticated user state for a request. // Built early in the middleware chain and available throughout request processing. // // Two-phase initialization: // 1. Middleware phase: Identity is set (DID, authMethod, action) // 2. Repository() phase: Target is set via SetTarget() (owner, repo, holdDID) type UserContext struct { // === User Identity (set in middleware) === DID string // User's DID (empty if unauthenticated) Handle string // User's handle (may be empty) PDSEndpoint string // User's PDS endpoint AuthMethod string // "oauth", "app_password", or "" IsAuthenticated bool // === Request Info === Action RequestAction HTTPMethod string // === Target Info (set by SetTarget) === TargetOwnerDID string // whose repo is being accessed TargetOwnerHandle string TargetOwnerPDS string TargetRepo string // image name (e.g., "quickslice") TargetHoldDID string // hold where blobs live/will live // === Dependencies (injected) === refresher *oauth.Refresher authorizer HoldAuthorizer defaultHoldDID string // === Cached State (lazy-loaded) === serviceTokens sync.Map // holdDID -> *serviceTokenEntry permissions sync.Map // holdDID -> *HoldPermissions pdsResolved bool pdsResolveErr error mu sync.Mutex // protects PDS resolution atprotoClient *atproto.Client atprotoClientOnce sync.Once } // FromContext retrieves UserContext from context. // Returns nil if not present (unauthenticated or before middleware). func FromContext(ctx context.Context) *UserContext { uc, _ := ctx.Value(userContextKey).(*UserContext) return uc } // WithUserContext adds UserContext to context func WithUserContext(ctx context.Context, uc *UserContext) context.Context { return context.WithValue(ctx, userContextKey, uc) } // NewUserContext creates a UserContext from extracted JWT claims. // The deps parameter provides access to services needed for lazy operations. func NewUserContext(did, authMethod, httpMethod string, deps *Dependencies) *UserContext { action := ActionUnknown switch httpMethod { case "GET", "HEAD": action = ActionPull case "PUT", "POST", "PATCH", "DELETE": action = ActionPush } var refresher *oauth.Refresher var authorizer HoldAuthorizer var defaultHoldDID string if deps != nil { refresher = deps.Refresher authorizer = deps.Authorizer defaultHoldDID = deps.DefaultHoldDID } return &UserContext{ DID: did, AuthMethod: authMethod, IsAuthenticated: did != "", Action: action, HTTPMethod: httpMethod, refresher: refresher, authorizer: authorizer, defaultHoldDID: defaultHoldDID, } } // SetPDS sets the user's PDS endpoint directly, bypassing network resolution. // Use when PDS is already known (e.g., from previous resolution or client). func (uc *UserContext) SetPDS(handle, pdsEndpoint string) { uc.mu.Lock() defer uc.mu.Unlock() uc.Handle = handle uc.PDSEndpoint = pdsEndpoint uc.pdsResolved = true uc.pdsResolveErr = nil } // SetTarget sets the target repository information. // Called in Repository() after resolving the owner identity. func (uc *UserContext) SetTarget(ownerDID, ownerHandle, ownerPDS, repo, holdDID string) { uc.TargetOwnerDID = ownerDID uc.TargetOwnerHandle = ownerHandle uc.TargetOwnerPDS = ownerPDS uc.TargetRepo = repo uc.TargetHoldDID = holdDID } // ResolvePDS resolves the user's PDS endpoint (lazy, cached). // Safe to call multiple times; resolution happens once. func (uc *UserContext) ResolvePDS(ctx context.Context) error { if !uc.IsAuthenticated { return nil // Nothing to resolve for anonymous users } uc.mu.Lock() defer uc.mu.Unlock() if uc.pdsResolved { return uc.pdsResolveErr } _, handle, pds, err := atproto.ResolveIdentity(ctx, uc.DID) if err != nil { uc.pdsResolveErr = err uc.pdsResolved = true return err } uc.Handle = handle uc.PDSEndpoint = pds uc.pdsResolved = true return nil } // GetServiceToken returns a service token for the target hold. // Uses internal caching with sync.Once per holdDID. // Requires target to be set via SetTarget(). func (uc *UserContext) GetServiceToken(ctx context.Context) (string, error) { if uc.TargetHoldDID == "" { return "", fmt.Errorf("target hold not set (call SetTarget first)") } return uc.GetServiceTokenForHold(ctx, uc.TargetHoldDID) } // GetServiceTokenForHold returns a service token for an arbitrary hold. // Uses internal caching with sync.Once per holdDID. func (uc *UserContext) GetServiceTokenForHold(ctx context.Context, holdDID string) (string, error) { if !uc.IsAuthenticated { return "", fmt.Errorf("cannot get service token: user not authenticated") } // Ensure PDS is resolved if err := uc.ResolvePDS(ctx); err != nil { return "", fmt.Errorf("failed to resolve PDS: %w", err) } // Load or create cache entry entryVal, _ := uc.serviceTokens.LoadOrStore(holdDID, &serviceTokenEntry{}) entry := entryVal.(*serviceTokenEntry) entry.once.Do(func() { slog.Debug("Fetching service token", "component", "auth/context", "userDID", uc.DID, "holdDID", holdDID, "authMethod", uc.AuthMethod) // Use unified service token function (handles both OAuth and app-password) serviceToken, err := GetOrFetchServiceToken( ctx, uc.AuthMethod, uc.refresher, uc.DID, holdDID, uc.PDSEndpoint, ) entry.token = serviceToken entry.err = err if err == nil { // Parse JWT to get expiry expiry, parseErr := ParseJWTExpiry(serviceToken) if parseErr == nil { entry.expiresAt = expiry.Add(-10 * time.Second) // Safety margin } else { entry.expiresAt = time.Now().Add(45 * time.Second) // Default fallback } } }) return entry.token, entry.err } // CanRead checks if user can read blobs from target hold. // - Public hold: any user (even anonymous) // - Private hold: owner OR crew with blob:read/blob:write func (uc *UserContext) CanRead(ctx context.Context) (bool, error) { if uc.TargetHoldDID == "" { return false, fmt.Errorf("target hold not set (call SetTarget first)") } if uc.authorizer == nil { return false, fmt.Errorf("authorizer not configured") } return uc.authorizer.CheckReadAccess(ctx, uc.TargetHoldDID, uc.DID) } // CanWrite checks if user can write blobs to target hold. // - Must be authenticated // - Must be owner OR crew with blob:write func (uc *UserContext) CanWrite(ctx context.Context) (bool, error) { if uc.TargetHoldDID == "" { return false, fmt.Errorf("target hold not set (call SetTarget first)") } if !uc.IsAuthenticated { return false, nil // Anonymous writes never allowed } if uc.authorizer == nil { return false, fmt.Errorf("authorizer not configured") } return uc.authorizer.CheckWriteAccess(ctx, uc.TargetHoldDID, uc.DID) } // GetPermissions returns detailed permissions for target hold. // Lazy-loaded and cached per holdDID. func (uc *UserContext) GetPermissions(ctx context.Context) (*HoldPermissions, error) { if uc.TargetHoldDID == "" { return nil, fmt.Errorf("target hold not set (call SetTarget first)") } return uc.GetPermissionsForHold(ctx, uc.TargetHoldDID) } // GetPermissionsForHold returns detailed permissions for an arbitrary hold. // Lazy-loaded and cached per holdDID. func (uc *UserContext) GetPermissionsForHold(ctx context.Context, holdDID string) (*HoldPermissions, error) { // Check cache first if cached, ok := uc.permissions.Load(holdDID); ok { return cached.(*HoldPermissions), nil } if uc.authorizer == nil { return nil, fmt.Errorf("authorizer not configured") } // Build permissions by querying authorizer captain, err := uc.authorizer.GetCaptainRecord(ctx, holdDID) if err != nil { return nil, fmt.Errorf("failed to get captain record: %w", err) } perms := &HoldPermissions{ HoldDID: holdDID, IsPublic: captain.Public, IsOwner: uc.DID != "" && uc.DID == captain.Owner, } // Check crew membership if authenticated and not owner if uc.IsAuthenticated && !perms.IsOwner { isCrew, crewErr := uc.authorizer.IsCrewMember(ctx, holdDID, uc.DID) if crewErr != nil { slog.Warn("Failed to check crew membership", "component", "auth/context", "holdDID", holdDID, "userDID", uc.DID, "error", crewErr) } perms.IsCrew = isCrew } // Compute permissions based on role if perms.IsOwner { perms.CanRead = true perms.CanWrite = true perms.CanAdmin = true } else if perms.IsCrew { // Crew members can read and write (for now, all crew have blob:write) // TODO: Check specific permissions from crew record perms.CanRead = true perms.CanWrite = true perms.CanAdmin = false } else if perms.IsPublic { // Public hold - anyone can read perms.CanRead = true perms.CanWrite = false perms.CanAdmin = false } else if uc.IsAuthenticated { // Private hold, authenticated non-crew // Per permission matrix: cannot read private holds perms.CanRead = false perms.CanWrite = false perms.CanAdmin = false } else { // Anonymous on private hold perms.CanRead = false perms.CanWrite = false perms.CanAdmin = false } // Cache and return uc.permissions.Store(holdDID, perms) return perms, nil } // IsCrewMember checks if user is crew of target hold. func (uc *UserContext) IsCrewMember(ctx context.Context) (bool, error) { if uc.TargetHoldDID == "" { return false, fmt.Errorf("target hold not set (call SetTarget first)") } if !uc.IsAuthenticated { return false, nil } if uc.authorizer == nil { return false, fmt.Errorf("authorizer not configured") } return uc.authorizer.IsCrewMember(ctx, uc.TargetHoldDID, uc.DID) } // EnsureCrewMembership is a standalone function to register as crew on a hold. // Use this when you don't have a UserContext (e.g., OAuth callback). // This is best-effort and logs errors without failing. func EnsureCrewMembership(ctx context.Context, did, pdsEndpoint string, refresher *oauth.Refresher, holdDID string) { if holdDID == "" { return } // Only works with OAuth (refresher required) - app passwords can't get service tokens if refresher == nil { slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID) return } // Normalize URL to DID if needed if !atproto.IsDID(holdDID) { holdDID = atproto.ResolveHoldDIDFromURL(holdDID) if holdDID == "" { slog.Warn("failed to resolve hold DID", "defaultHold", holdDID) return } } // Get service token for the hold (OAuth only at this point) serviceToken, err := GetOrFetchServiceToken(ctx, AuthMethodOAuth, refresher, did, holdDID, pdsEndpoint) if err != nil { slog.Warn("failed to get service token", "holdDID", holdDID, "error", err) return } // Resolve hold DID to HTTP endpoint holdEndpoint := atproto.ResolveHoldURL(holdDID) if holdEndpoint == "" { slog.Warn("failed to resolve hold endpoint", "holdDID", holdDID) return } // Call requestCrew endpoint if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil { slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err) return } slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", did) } // ensureCrewMembership attempts to register as crew on target hold (UserContext method). // Called automatically during first push; idempotent. // This is a best-effort operation and logs errors without failing. // Requires SetTarget() to be called first. func (uc *UserContext) ensureCrewMembership(ctx context.Context) error { if uc.TargetHoldDID == "" { return fmt.Errorf("target hold not set (call SetTarget first)") } return uc.EnsureCrewMembershipForHold(ctx, uc.TargetHoldDID) } // EnsureCrewMembershipForHold attempts to register as crew on the specified hold. // This is the core implementation that can be called with any holdDID. // Called automatically during first push; idempotent. // This is a best-effort operation and logs errors without failing. func (uc *UserContext) EnsureCrewMembershipForHold(ctx context.Context, holdDID string) error { if holdDID == "" { return nil // Nothing to do } // Normalize URL to DID if needed if !atproto.IsDID(holdDID) { holdDID = atproto.ResolveHoldDIDFromURL(holdDID) if holdDID == "" { return fmt.Errorf("failed to resolve hold DID from URL") } } if !uc.IsAuthenticated { return fmt.Errorf("cannot register as crew: user not authenticated") } if uc.refresher == nil { return fmt.Errorf("cannot register as crew: OAuth session required") } // Get service token for the hold serviceToken, err := uc.GetServiceTokenForHold(ctx, holdDID) if err != nil { return fmt.Errorf("failed to get service token: %w", err) } // Resolve hold DID to HTTP endpoint holdEndpoint := atproto.ResolveHoldURL(holdDID) if holdEndpoint == "" { return fmt.Errorf("failed to resolve hold endpoint for %s", holdDID) } // Call requestCrew endpoint return requestCrewMembership(ctx, holdEndpoint, serviceToken) } // requestCrewMembership calls the hold's requestCrew endpoint // The endpoint handles all authorization and duplicate checking internally func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error { // Add 5 second timeout to prevent hanging on offline holds ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+serviceToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { // Read response body to capture actual error message from hold body, readErr := io.ReadAll(resp.Body) if readErr != nil { return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr) } return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body)) } return nil } // GetUserClient returns an authenticated ATProto client for the user's own PDS. // Used for profile operations (reading/writing to user's own repo). // Returns nil if not authenticated or PDS not resolved. func (uc *UserContext) GetUserClient() *atproto.Client { if !uc.IsAuthenticated || uc.PDSEndpoint == "" { return nil } if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil { return atproto.NewClientWithSessionProvider(uc.PDSEndpoint, uc.DID, uc.refresher) } else if uc.AuthMethod == AuthMethodAppPassword { accessToken, _ := GetGlobalTokenCache().Get(uc.DID) return atproto.NewClient(uc.PDSEndpoint, uc.DID, accessToken) } return nil } // EnsureUserSetup ensures the user has a profile and crew membership. // Called once per user (cached for userSetupTTL). Runs in background - does not block. // Safe to call on every request. func (uc *UserContext) EnsureUserSetup() { if !uc.IsAuthenticated || uc.DID == "" { return } // Check cache - skip if recently set up if lastSetup, ok := userSetupCache.Load(uc.DID); ok { if time.Since(lastSetup.(time.Time)) < userSetupTTL { return } } // Run in background to avoid blocking requests go func() { bgCtx := context.Background() // 1. Ensure profile exists if client := uc.GetUserClient(); client != nil { uc.ensureProfile(bgCtx, client) } // 2. Ensure crew membership on default hold if uc.defaultHoldDID != "" { EnsureCrewMembership(bgCtx, uc.DID, uc.PDSEndpoint, uc.refresher, uc.defaultHoldDID) } // Mark as set up userSetupCache.Store(uc.DID, time.Now()) slog.Debug("User setup complete", "component", "auth/usercontext", "did", uc.DID, "defaultHoldDID", uc.defaultHoldDID) }() } // ensureProfile creates sailor profile if it doesn't exist. // Inline implementation to avoid circular import with storage package. func (uc *UserContext) ensureProfile(ctx context.Context, client *atproto.Client) { // Check if profile already exists profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self") if err == nil && profile != nil { return // Already exists } // Create profile with default hold normalizedDID := "" if uc.defaultHoldDID != "" { normalizedDID = atproto.ResolveHoldDIDFromURL(uc.defaultHoldDID) } newProfile := atproto.NewSailorProfileRecord(normalizedDID) if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, "self", newProfile); err != nil { slog.Warn("Failed to create sailor profile", "component", "auth/usercontext", "did", uc.DID, "error", err) return } slog.Debug("Created sailor profile", "component", "auth/usercontext", "did", uc.DID, "defaultHold", normalizedDID) } // GetATProtoClient returns a cached ATProto client for the target owner's PDS. // Authenticated if user is owner, otherwise anonymous. // Cached per-request (uses sync.Once). func (uc *UserContext) GetATProtoClient() *atproto.Client { uc.atprotoClientOnce.Do(func() { if uc.TargetOwnerPDS == "" { return } // If puller is owner and authenticated, use authenticated client if uc.DID == uc.TargetOwnerDID && uc.IsAuthenticated { if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil { uc.atprotoClient = atproto.NewClientWithSessionProvider(uc.TargetOwnerPDS, uc.TargetOwnerDID, uc.refresher) return } else if uc.AuthMethod == AuthMethodAppPassword { accessToken, _ := GetGlobalTokenCache().Get(uc.TargetOwnerDID) uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, accessToken) return } } // Anonymous client for reads uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "") }) return uc.atprotoClient } // ResolveHoldDID finds the hold for the target repository. // - Pull: uses database lookup (historical from manifest) // - Push: uses discovery (sailor profile → default) // // Must be called after SetTarget() is called with at least TargetOwnerDID and TargetRepo set. // Updates TargetHoldDID on success. func (uc *UserContext) ResolveHoldDID(ctx context.Context, sqlDB *sql.DB) (string, error) { if uc.TargetOwnerDID == "" { return "", fmt.Errorf("target owner not set") } var holdDID string var err error switch uc.Action { case ActionPull: // For pulls, look up historical hold from database holdDID, err = uc.resolveHoldForPull(ctx, sqlDB) case ActionPush: // For pushes, discover hold from owner's profile holdDID, err = uc.resolveHoldForPush(ctx) default: // Default to push discovery holdDID, err = uc.resolveHoldForPush(ctx) } if err != nil { return "", err } if holdDID == "" { return "", fmt.Errorf("no hold DID found for %s/%s", uc.TargetOwnerDID, uc.TargetRepo) } uc.TargetHoldDID = holdDID return holdDID, nil } // resolveHoldForPull looks up the hold from the database (historical reference) func (uc *UserContext) resolveHoldForPull(ctx context.Context, sqlDB *sql.DB) (string, error) { // If no database is available, fall back to discovery if sqlDB == nil { return uc.resolveHoldForPush(ctx) } // Try database lookup first holdDID, err := db.GetLatestHoldDIDForRepo(sqlDB, uc.TargetOwnerDID, uc.TargetRepo) if err != nil { slog.Debug("Database lookup failed, falling back to discovery", "component", "auth/context", "ownerDID", uc.TargetOwnerDID, "repo", uc.TargetRepo, "error", err) return uc.resolveHoldForPush(ctx) } if holdDID != "" { return holdDID, nil } // No historical hold found, fall back to discovery return uc.resolveHoldForPush(ctx) } // resolveHoldForPush discovers hold from owner's sailor profile or default func (uc *UserContext) resolveHoldForPush(ctx context.Context) (string, error) { // Create anonymous client to query owner's profile client := atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "") // Try to get owner's sailor profile record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self") if err == nil && record != nil { var profile atproto.SailorProfileRecord if jsonErr := json.Unmarshal(record.Value, &profile); jsonErr == nil { if profile.DefaultHold != "" { // Normalize to DID if needed holdDID := profile.DefaultHold if !atproto.IsDID(holdDID) { holdDID = atproto.ResolveHoldDIDFromURL(holdDID) } slog.Debug("Found hold from owner's profile", "component", "auth/context", "ownerDID", uc.TargetOwnerDID, "holdDID", holdDID) return holdDID, nil } } } // Fall back to default hold if uc.defaultHoldDID != "" { slog.Debug("Using default hold", "component", "auth/context", "ownerDID", uc.TargetOwnerDID, "defaultHoldDID", uc.defaultHoldDID) return uc.defaultHoldDID, nil } return "", fmt.Errorf("no hold configured for %s and no default hold set", uc.TargetOwnerDID) } // ============================================================================= // Test Helper Methods // ============================================================================= // These methods are designed to make UserContext testable by allowing tests // to bypass network-dependent code paths (PDS resolution, OAuth token fetching). // Only use these in tests - they are not intended for production use. // SetPDSForTest sets the PDS endpoint directly, bypassing ResolvePDS network calls. // This allows tests to skip DID resolution which would make network requests. // Deprecated: Use SetPDS instead. func (uc *UserContext) SetPDSForTest(handle, pdsEndpoint string) { uc.SetPDS(handle, pdsEndpoint) } // SetServiceTokenForTest pre-populates a service token for the given holdDID, // bypassing the sync.Once and OAuth/app-password fetching logic. // The token will appear as if it was already fetched and cached. func (uc *UserContext) SetServiceTokenForTest(holdDID, token string) { entry := &serviceTokenEntry{ token: token, expiresAt: time.Now().Add(5 * time.Minute), err: nil, } // Mark the sync.Once as done so real fetch won't happen entry.once.Do(func() {}) uc.serviceTokens.Store(holdDID, entry) } // SetAuthorizerForTest sets the authorizer for permission checks. // Use with MockHoldAuthorizer to control CanRead/CanWrite behavior in tests. func (uc *UserContext) SetAuthorizerForTest(authorizer HoldAuthorizer) { uc.authorizer = authorizer } // SetDefaultHoldDIDForTest sets the default hold DID for tests. // This is used as fallback when resolving hold for push operations. func (uc *UserContext) SetDefaultHoldDIDForTest(holdDID string) { uc.defaultHoldDID = holdDID }