package token import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "github.com/distribution/distribution/v3/registry/api/errcode" "github.com/go-chi/render" ) // PostAuthCallback is called after successful Basic Auth authentication. // Parameters: ctx, did, handle, pdsEndpoint, accessToken // This allows AppView to perform business logic (profile creation, etc.) // without coupling the token package to AppView-specific dependencies. type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error // OAuthSessionValidator validates OAuth sessions before issuing tokens // This interface allows the token handler to verify OAuth sessions are usable // (not just that they exist) without depending directly on the OAuth implementation. type OAuthSessionValidator interface { // ValidateSession checks if OAuth session is usable by attempting to load/refresh it // Returns nil if session is valid, error if session is invalid/expired/needs re-auth ValidateSession(ctx context.Context, did string) error } // Authorizer gates issuance of registry JWTs at the auth phase. // Implementations decide which sub-checks apply based on `access`: // crew reconciliation runs for any token request (so first-time CLI users // can pull from a private hold), while membership and quota enforcement // only apply to non-wildcard push scopes. The Docker spec model is to // embed authorization in the JWT and trust it for its short lifetime; this // is the single point where those gates run. type Authorizer interface { // Authorize is called once per token issuance, after the requester's // ATProto credentials have been validated. Returns nil if `access` is // allowed, or an error describing the denial reason. The error message // surfaces to the OCI client inside the distribution error JSON body. // // authMethod is one of AuthMethodOAuth or AuthMethodAppPassword and lets // the implementation pick the right service-token fetcher when it needs // to talk to the hold (e.g. for crew reconciliation). Authorize(ctx context.Context, did, authMethod string, access []auth.AccessEntry) error } // ServiceAuthFetcher pre-mints the AppView↔hold service-auth at /auth/token // time so the registry JWT can be bound to its lifetime. JWT and service-auth // then expire concurrently; when Docker hits 401, the next /auth/token call // mints both fresh in lockstep. type ServiceAuthFetcher interface { // Fetch ensures a service-auth exists for (did, hold derived from did) // and returns its expiry. Returns (zero time, nil) when the user has no // hold configured — caller falls back to the issuer's default exp. Fetch(ctx context.Context, did, authMethod string) (expiresAt time.Time, err error) } // Handler handles /auth/token requests type Handler struct { issuer *Issuer validator *auth.SessionValidator deviceStore *db.DeviceStore // For validating device secrets postAuthCallback PostAuthCallback oauthSessionValidator OAuthSessionValidator authorizer Authorizer serviceAuthFetcher ServiceAuthFetcher } // NewHandler creates a new token handler func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler { return &Handler{ issuer: issuer, validator: auth.NewSessionValidator(), deviceStore: deviceStore, } } // SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication // This allows AppView to inject business logic without coupling the token package func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) { h.postAuthCallback = callback } // SetOAuthSessionValidator sets the OAuth session validator for validating device auth // When set, the handler will validate OAuth sessions are usable before issuing tokens for device auth // This prevents the flood of errors that occurs when a stale session is discovered during push func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) { h.oauthSessionValidator = validator } // SetAuthorizer wires the auth-phase gate. When set, every token request // runs Authorize after credentials validate; a non-nil error is returned to // the client as a 403 (distribution error JSON) and the JWT is not issued. func (h *Handler) SetAuthorizer(authorizer Authorizer) { h.authorizer = authorizer } // SetServiceAuthFetcher binds JWT issuance to the AppView↔hold service-auth. // When set, the handler pre-mints the service-auth and stamps the JWT's exp // from the cached expiry, so both tokens expire concurrently. func (h *Handler) SetServiceAuthFetcher(fetcher ServiceAuthFetcher) { h.serviceAuthFetcher = fetcher } // TokenResponse represents the response from /auth/token type TokenResponse struct { Token string `json:"token,omitempty"` // Legacy field AccessToken string `json:"access_token,omitempty"` // Standard field ExpiresIn int `json:"expires_in,omitempty"` IssuedAt string `json:"issued_at,omitempty"` } // getBaseURL extracts the base URL from the request, handling proxies func getBaseURL(r *http.Request) string { baseURL := r.Header.Get("X-Forwarded-Host") if baseURL == "" { baseURL = r.Host } if !strings.HasPrefix(baseURL, "http") { // Add scheme if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { baseURL = "https://" + baseURL } else { baseURL = "http://" + baseURL } } return baseURL } // sendAuthError sends a formatted authentication error response func sendAuthError(w http.ResponseWriter, r *http.Request, message string) { baseURL := getBaseURL(r) w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) http.Error(w, fmt.Sprintf(`%s To authenticate: 1. Install credential helper: %s/install 2. Or run: docker login %s (use your ATProto handle + app-password)`, message, baseURL, r.Host), http.StatusUnauthorized) } // AuthErrorResponse is returned when authentication fails in a way the credential helper can handle type AuthErrorResponse struct { Error string `json:"error"` Message string `json:"message"` LoginURL string `json:"login_url,omitempty"` } // sendOAuthSessionExpiredError sends a JSON error response when OAuth session is missing // This allows the credential helper to detect this specific error and open the browser func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) { baseURL := getBaseURL(r) loginURL := baseURL + "/auth/oauth/login" w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) resp := AuthErrorResponse{ Error: "oauth_session_expired", Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.", LoginURL: loginURL, } render.JSON(w, r, resp) } // ServeHTTP handles the token request func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path) // Only accept GET requests (per Docker spec) if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Extract Basic auth credentials username, password, ok := r.BasicAuth() if !ok { slog.Debug("No Basic auth credentials provided") sendAuthError(w, r, "authentication required") return } // Reconstruct DID usernames that were mangled by BasicAuth's colon split username, password = parseBasicAuthDID(username, password) slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) // Parse query parameters _ = r.URL.Query().Get("service") // service parameter - validated by issuer scopeParam := r.URL.Query().Get("scope") // Parse scopes var scopes []string if scopeParam != "" { scopes = strings.Split(scopeParam, " ") } access, err := auth.ParseScope(scopes) if err != nil { http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) return } var did string var handle string var accessToken string var authMethod string // 1. Check if it's a device secret (starts with "atcr_device_") if strings.HasPrefix(password, "atcr_device_") { device, err := h.deviceStore.ValidateDeviceSecret(password) if err != nil { slog.Debug("Device secret validation failed", "error", err) sendAuthError(w, r, "authentication failed") return } // Validate OAuth session is usable (not just exists) // Device secrets are permanent, but they require a working OAuth session to push // By validating here, we prevent the flood of errors that occurs when a stale // session is discovered during parallel layer uploads if h.oauthSessionValidator != nil { if err := h.oauthSessionValidator.ValidateSession(r.Context(), device.DID); err != nil { slog.Debug("OAuth session validation failed", "did", device.DID, "error", err) sendOAuthSessionExpiredError(w, r) return } } did = device.DID handle = device.Handle authMethod = AuthMethodOAuth // Device is linked to OAuth session via DID // OAuth refresher will provide access token when needed via middleware } else { // 2. Try app password (direct PDS authentication) slog.Debug("Trying app password authentication", "username", username) did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) if err != nil { // Log at WARN level with specific error type if errors.Is(err, auth.ErrIdentityResolution) { slog.Warn("Identity resolution failed", "error", err, "username", username) sendAuthError(w, r, "authentication failed: could not resolve handle") } else if errors.Is(err, auth.ErrInvalidCredentials) { slog.Warn("Invalid credentials", "username", username) sendAuthError(w, r, "authentication failed: invalid credentials") } else if errors.Is(err, auth.ErrPDSUnavailable) { slog.Warn("PDS unavailable", "error", err, "username", username) sendAuthError(w, r, "authentication failed: PDS unavailable") } else { slog.Warn("Authentication failed", "error", err, "username", username) sendAuthError(w, r, "authentication failed") } return } authMethod = AuthMethodAppPassword slog.Debug("App password validated successfully", "did", did, "handle", handle, "accessTokenLength", len(accessToken)) // Cache the access token for later use (e.g., when pushing manifests) // TTL of 2 hours (ATProto tokens typically last longer) auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) slog.Debug("Cached access token", "did", did) // Call post-auth callback for AppView business logic (profile management, etc.) if h.postAuthCallback != nil { // Resolve PDS endpoint for callback _, _, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), username) if err != nil { // Log error but don't fail auth - profile management is not critical slog.Warn("Failed to resolve PDS for callback", "error", err, "username", username) } else { if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil { // Log error but don't fail auth - business logic is non-critical slog.Warn("Post-auth callback failed", "error", err, "did", did) } } } } // Validate that the user has permission for the requested access // Use the actual handle from the validated credentials, not the Basic Auth username if err := auth.ValidateAccess(did, handle, access); err != nil { slog.Debug("Access validation failed", "error", err, "did", did) http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) return } // Auth-phase gate (crew reconciliation, plus membership/quota for // non-wildcard push) and service-auth pre-mint are independent network // paths — one talks to the hold, the other to the PDS. Run them in // parallel so a cold /auth/token pays max(both) instead of sum(both). // Nil-authorizer / nil-fetcher configurations short-circuit each branch // independently. The gate runs for pull too: first-time CLI users on a // private hold need crew reconciliation before the hold-side read check // will accept the resulting JWT. runGate := h.authorizer != nil runFetch := h.serviceAuthFetcher != nil type gateRes struct{ err error } type fetchRes struct { expiresAt time.Time err error } var ( gateCh chan gateRes fetchCh chan fetchRes ) if runGate { gateCh = make(chan gateRes, 1) go func() { gateCh <- gateRes{err: h.authorizer.Authorize(r.Context(), did, authMethod, access)} }() } if runFetch { fetchCh = make(chan fetchRes, 1) go func() { exp, err := h.serviceAuthFetcher.Fetch(r.Context(), did, authMethod) fetchCh <- fetchRes{expiresAt: exp, err: err} }() } // Drain the gate first so a denial wins over a transient fetch error. if runGate { if res := <-gateCh; res.err != nil { slog.Info("Authorization denied", "did", did, "error", res.err) _ = errcode.ServeJSON(w, errcode.ErrorCodeDenied.WithMessage(res.err.Error())) return } } // Bind JWT lifetime to the AppView↔hold service-auth: pre-mint it now and // stamp the JWT's exp from the cached expiry. They expire concurrently; // when Docker hits 401, the next /auth/token call mints both fresh. issueExp := h.issuer.expiration if runFetch { res := <-fetchCh if res.err != nil { slog.Warn("service-auth pre-mint failed", "did", did, "error", res.err) _ = errcode.ServeJSON(w, errcode.ErrorCodeUnavailable.WithMessage(fmt.Sprintf("service-auth fetch failed: %v", res.err))) return } if !res.expiresAt.IsZero() { // Cap JWT lifetime at the service-auth's expiry. The cache's // expiresAt already includes a 10s safety margin // (pkg/auth/cache.go:71), so this guarantees the service-auth // is still cache-valid for any /v2/* request the JWT can // authorize. We never extend beyond the configured default. until := time.Until(res.expiresAt) if until < issueExp { issueExp = until } } } // Issue JWT token tokenString, err := h.issuer.IssueWithExpiration(did, access, authMethod, issueExp) if err != nil { slog.Error("Failed to issue token", "error", err, "did", did) http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) return } slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod) // Return token response now := time.Now() expiresIn := int(issueExp.Seconds()) resp := TokenResponse{ Token: tokenString, AccessToken: tokenString, ExpiresIn: expiresIn, IssuedAt: now.Format(time.RFC3339), } render.JSON(w, r, resp) } // parseBasicAuthDID fixes DID usernames that are mangled by HTTP Basic Auth. // // This handles two cases: // 1. Hyphen-encoded DIDs (did-plc-abc123) — converted to did:plc:abc123. // This is the recommended format for tools like helm that reject colons in usernames. // 2. Raw DIDs split by BasicAuth — "did:plc:abc123" gets split on the first colon // into username="did", password="plc:abc123:". Reconstructed here. func parseBasicAuthDID(username, password string) (string, string) { // Case 1: Hyphen-encoded DID (e.g., did-plc-abc123 or did-web-example.com) if did, ok := auth.DecodeDIDFromHyphens(username); ok { return did, password } // Case 2: Raw DID was split by BasicAuth on the first colon // username="did", password="plc::" or "web::" if username != "did" { return username, password } if after, ok := strings.CutPrefix(password, "plc:"); ok { rest := after if idx := strings.Index(rest, ":"); idx > 0 { return "did:plc:" + rest[:idx], rest[idx+1:] } } else if after, ok := strings.CutPrefix(password, "web:"); ok { rest := after if idx := strings.Index(rest, ":"); idx > 0 { return "did:web:" + rest[:idx], rest[idx+1:] } } return username, password }