A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
at codeberg-source 264 lines 9.4 kB view raw
1package token 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 "atcr.io/pkg/appview/db" 13 "atcr.io/pkg/atproto" 14 "atcr.io/pkg/auth" 15) 16 17// PostAuthCallback is called after successful Basic Auth authentication. 18// Parameters: ctx, did, handle, pdsEndpoint, accessToken 19// This allows AppView to perform business logic (profile creation, etc.) 20// without coupling the token package to AppView-specific dependencies. 21type PostAuthCallback func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error 22 23// OAuthSessionValidator validates OAuth sessions before issuing tokens 24// This interface allows the token handler to verify OAuth sessions are usable 25// (not just that they exist) without depending directly on the OAuth implementation. 26type OAuthSessionValidator interface { 27 // ValidateSession checks if OAuth session is usable by attempting to load/refresh it 28 // Returns nil if session is valid, error if session is invalid/expired/needs re-auth 29 ValidateSession(ctx context.Context, did string) error 30} 31 32// Handler handles /auth/token requests 33type Handler struct { 34 issuer *Issuer 35 validator *auth.SessionValidator 36 deviceStore *db.DeviceStore // For validating device secrets 37 postAuthCallback PostAuthCallback 38 oauthSessionValidator OAuthSessionValidator 39} 40 41// NewHandler creates a new token handler 42func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore) *Handler { 43 return &Handler{ 44 issuer: issuer, 45 validator: auth.NewSessionValidator(), 46 deviceStore: deviceStore, 47 } 48} 49 50// SetPostAuthCallback sets the callback to be invoked after successful Basic Auth authentication 51// This allows AppView to inject business logic without coupling the token package 52func (h *Handler) SetPostAuthCallback(callback PostAuthCallback) { 53 h.postAuthCallback = callback 54} 55 56// SetOAuthSessionValidator sets the OAuth session validator for validating device auth 57// When set, the handler will validate OAuth sessions are usable before issuing tokens for device auth 58// This prevents the flood of errors that occurs when a stale session is discovered during push 59func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) { 60 h.oauthSessionValidator = validator 61} 62 63// TokenResponse represents the response from /auth/token 64type TokenResponse struct { 65 Token string `json:"token,omitempty"` // Legacy field 66 AccessToken string `json:"access_token,omitempty"` // Standard field 67 ExpiresIn int `json:"expires_in,omitempty"` 68 IssuedAt string `json:"issued_at,omitempty"` 69} 70 71// getBaseURL extracts the base URL from the request, handling proxies 72func getBaseURL(r *http.Request) string { 73 baseURL := r.Header.Get("X-Forwarded-Host") 74 if baseURL == "" { 75 baseURL = r.Host 76 } 77 if !strings.HasPrefix(baseURL, "http") { 78 // Add scheme 79 if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 80 baseURL = "https://" + baseURL 81 } else { 82 baseURL = "http://" + baseURL 83 } 84 } 85 return baseURL 86} 87 88// sendAuthError sends a formatted authentication error response 89func sendAuthError(w http.ResponseWriter, r *http.Request, message string) { 90 baseURL := getBaseURL(r) 91 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 92 http.Error(w, fmt.Sprintf(`%s 93 94To authenticate: 95 1. Install credential helper: %s/install 96 2. Or run: docker login %s 97 (use your ATProto handle + app-password)`, message, baseURL, r.Host), http.StatusUnauthorized) 98} 99 100// AuthErrorResponse is returned when authentication fails in a way the credential helper can handle 101type AuthErrorResponse struct { 102 Error string `json:"error"` 103 Message string `json:"message"` 104 LoginURL string `json:"login_url,omitempty"` 105} 106 107// sendOAuthSessionExpiredError sends a JSON error response when OAuth session is missing 108// This allows the credential helper to detect this specific error and open the browser 109func sendOAuthSessionExpiredError(w http.ResponseWriter, r *http.Request) { 110 baseURL := getBaseURL(r) 111 loginURL := baseURL + "/auth/oauth/login" 112 113 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 114 w.Header().Set("Content-Type", "application/json") 115 w.WriteHeader(http.StatusUnauthorized) 116 117 resp := AuthErrorResponse{ 118 Error: "oauth_session_expired", 119 Message: "OAuth session expired or invalidated. Please re-authenticate in your browser.", 120 LoginURL: loginURL, 121 } 122 json.NewEncoder(w).Encode(resp) 123} 124 125// ServeHTTP handles the token request 126func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 127 slog.Debug("Received token request", "method", r.Method, "path", r.URL.Path) 128 129 // Only accept GET requests (per Docker spec) 130 if r.Method != http.MethodGet { 131 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 132 return 133 } 134 135 // Extract Basic auth credentials 136 username, password, ok := r.BasicAuth() 137 if !ok { 138 slog.Debug("No Basic auth credentials provided") 139 sendAuthError(w, r, "authentication required") 140 return 141 } 142 143 slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) 144 145 // Parse query parameters 146 _ = r.URL.Query().Get("service") // service parameter - validated by issuer 147 scopeParam := r.URL.Query().Get("scope") 148 149 // Parse scopes 150 var scopes []string 151 if scopeParam != "" { 152 scopes = strings.Split(scopeParam, " ") 153 } 154 155 access, err := auth.ParseScope(scopes) 156 if err != nil { 157 http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) 158 return 159 } 160 161 var did string 162 var handle string 163 var accessToken string 164 var authMethod string 165 166 // 1. Check if it's a device secret (starts with "atcr_device_") 167 if strings.HasPrefix(password, "atcr_device_") { 168 device, err := h.deviceStore.ValidateDeviceSecret(password) 169 if err != nil { 170 slog.Debug("Device secret validation failed", "error", err) 171 sendAuthError(w, r, "authentication failed") 172 return 173 } 174 175 // Validate OAuth session is usable (not just exists) 176 // Device secrets are permanent, but they require a working OAuth session to push 177 // By validating here, we prevent the flood of errors that occurs when a stale 178 // session is discovered during parallel layer uploads 179 if h.oauthSessionValidator != nil { 180 if err := h.oauthSessionValidator.ValidateSession(r.Context(), device.DID); err != nil { 181 slog.Debug("OAuth session validation failed", "did", device.DID, "error", err) 182 sendOAuthSessionExpiredError(w, r) 183 return 184 } 185 } 186 187 did = device.DID 188 handle = device.Handle 189 authMethod = AuthMethodOAuth 190 // Device is linked to OAuth session via DID 191 // OAuth refresher will provide access token when needed via middleware 192 } else { 193 // 2. Try app password (direct PDS authentication) 194 slog.Debug("Trying app password authentication", "username", username) 195 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 196 if err != nil { 197 slog.Debug("App password validation failed", "error", err, "username", username) 198 sendAuthError(w, r, "authentication failed") 199 return 200 } 201 202 authMethod = AuthMethodAppPassword 203 204 slog.Debug("App password validated successfully", 205 "did", did, 206 "handle", handle, 207 "accessTokenLength", len(accessToken)) 208 209 // Cache the access token for later use (e.g., when pushing manifests) 210 // TTL of 2 hours (ATProto tokens typically last longer) 211 auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 212 slog.Debug("Cached access token", "did", did) 213 214 // Call post-auth callback for AppView business logic (profile management, etc.) 215 if h.postAuthCallback != nil { 216 // Resolve PDS endpoint for callback 217 _, _, pdsEndpoint, err := atproto.ResolveIdentity(r.Context(), username) 218 if err != nil { 219 // Log error but don't fail auth - profile management is not critical 220 slog.Warn("Failed to resolve PDS for callback", "error", err, "username", username) 221 } else { 222 if err := h.postAuthCallback(r.Context(), did, handle, pdsEndpoint, accessToken); err != nil { 223 // Log error but don't fail auth - business logic is non-critical 224 slog.Warn("Post-auth callback failed", "error", err, "did", did) 225 } 226 } 227 } 228 } 229 230 // Validate that the user has permission for the requested access 231 // Use the actual handle from the validated credentials, not the Basic Auth username 232 if err := auth.ValidateAccess(did, handle, access); err != nil { 233 slog.Debug("Access validation failed", "error", err, "did", did) 234 http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 235 return 236 } 237 238 // Issue JWT token 239 tokenString, err := h.issuer.Issue(did, access, authMethod) 240 if err != nil { 241 slog.Error("Failed to issue token", "error", err, "did", did) 242 http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 243 return 244 } 245 246 slog.Debug("Issued JWT token", "tokenLength", len(tokenString), "did", did, "authMethod", authMethod) 247 248 // Return token response 249 now := time.Now() 250 expiresIn := int(h.issuer.expiration.Seconds()) 251 252 resp := TokenResponse{ 253 Token: tokenString, 254 AccessToken: tokenString, 255 ExpiresIn: expiresIn, 256 IssuedAt: now.Format(time.RFC3339), 257 } 258 259 w.Header().Set("Content-Type", "application/json") 260 if err := json.NewEncoder(w).Encode(resp); err != nil { 261 http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 262 return 263 } 264}