Something went wrong. Try again.
A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
Something went wrong. Try again.
11 kB · 314 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315package token
import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "strings" "time"
"atcr.io/pkg/appview/db" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/serviceauth")
// 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}
// Handler handles /auth/token requeststype Handler struct { issuer *Issuer validator *auth.SessionValidator deviceStore *db.DeviceStore // For validating device secrets postAuthCallback PostAuthCallback oauthSessionValidator OAuthSessionValidator serviceTokenValidator *serviceauth.Validator // For CI service token authentication}
// NewHandler creates a new token handlerfunc 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 packagefunc (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 pushfunc (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) { h.oauthSessionValidator = validator}
// SetServiceTokenValidator sets the service token validator for CI authentication// When set, the handler will accept Bearer tokens with service tokens from CI platforms// serviceDID is the DID of this service (e.g., "did:web:atcr.io")func (h *Handler) SetServiceTokenValidator(serviceDID string) { h.serviceTokenValidator = serviceauth.NewValidator(serviceDID)}
// TokenResponse represents the response from /auth/tokentype 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 proxiesfunc 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 responsefunc 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 handletype 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 browserfunc 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, } json.NewEncoder(w).Encode(resp)}
// ServeHTTP handles the token requestfunc (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 }
// 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
// Check for Bearer token authentication (CI service tokens) authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") && h.serviceTokenValidator != nil { tokenString := strings.TrimPrefix(authHeader, "Bearer ")
slog.Debug("Processing service token authentication")
validatedUser, err := h.serviceTokenValidator.Validate(r.Context(), tokenString) if err != nil { slog.Debug("Service token validation failed", "error", err) http.Error(w, fmt.Sprintf("service token authentication failed: %v", err), http.StatusUnauthorized) return }
did = validatedUser.DID authMethod = AuthMethodServiceToken
slog.Debug("Service token validated successfully", "did", did)
// Resolve handle from DID for access validation resolvedDID, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(r.Context(), did) if resolveErr != nil { slog.Warn("Failed to resolve handle for service token user", "did", did, "error", resolveErr) // Use empty handle - access validation will use DID } else { did = resolvedDID // Use canonical DID from resolution handle = resolvedHandle }
// Service token auth - issue token and return h.issueToken(w, r, did, handle, access, authMethod) 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 }
slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
// 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 { slog.Debug("App password validation 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) } } } }
// Issue token using common helper h.issueToken(w, r, did, handle, access, authMethod)}
// issueToken validates access and issues a JWT token// This is the common code path for all authentication methodsfunc (h *Handler) issueToken(w http.ResponseWriter, r *http.Request, did, handle string, access []auth.AccessEntry, authMethod string) { // 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 }
// Issue JWT token tokenString, err := h.issuer.Issue(did, access, authMethod) 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(h.issuer.expiration.Seconds())
resp := TokenResponse{ Token: tokenString, AccessToken: tokenString, ExpiresIn: expiresIn, IssuedAt: now.Format(time.RFC3339), }
w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) return }}