A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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