forked from
evan.jarrett.net/at-container-registry
A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}