1// Package auth implements the HTTP adapter for authentication endpoints.
2package auth
3
4import (
5 "context"
6 "crypto/subtle"
7 "encoding/json"
8 "net/http"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/internal/adapters/dto"
15 "github.com/bnema/gordon/internal/adapters/in/http/httputil"
16 "github.com/bnema/gordon/internal/boundaries/in"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// InternalAuth holds credentials for internal loopback registry access.
21type InternalAuth struct {
22 Username string
23 Password string
24}
25
26// Handler handles authentication requests at /auth/*.
27type Handler struct {
28 authSvc in.AuthService
29 internalAuth InternalAuth
30 log zerowrap.Logger
31}
32
33// NewHandler creates a new auth handler.
34func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
35 return &Handler{
36 authSvc: authSvc,
37 internalAuth: internalAuth,
38 log: log,
39 }
40}
41
42// PasswordRequest represents the request body for POST /auth/password.
43type PasswordRequest struct {
44 Username string `json:"username"`
45 Password string `json:"password"`
46}
47
48// PasswordResponse represents the response from POST /auth/password.
49type PasswordResponse struct {
50 Token string `json:"token"`
51 ExpiresIn int `json:"expires_in"`
52 IssuedAt string `json:"issued_at"`
53}
54
55const passwordSessionTTL = 24 * time.Hour
56
57func setNoStoreHeaders(w http.ResponseWriter) {
58 w.Header().Set("Cache-Control", "no-store")
59 w.Header().Set("Pragma", "no-cache")
60}
61
62// ServeHTTP routes requests to the appropriate handler.
63func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
64 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
65
66 switch path {
67 case "/password":
68 h.handlePassword(w, r)
69 case "/token":
70 h.handleToken(w, r)
71 default:
72 w.Header().Set("Content-Type", "application/json")
73 w.WriteHeader(http.StatusNotFound)
74 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
75 }
76}
77
78// handlePassword handles POST /auth/password requests.
79// Validates username/password and returns a daily session JWT.
80// Only works when auth type is "password" - returns error for "token" auth type.
81func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
82 setNoStoreHeaders(w)
83
84 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85 zerowrap.FieldLayer: "adapter",
86 zerowrap.FieldAdapter: "http",
87 zerowrap.FieldHandler: "auth",
88 zerowrap.FieldMethod: r.Method,
89 zerowrap.FieldPath: r.URL.Path,
90 })
91 log := zerowrap.FromCtx(ctx)
92
93 if r.Method != http.MethodPost {
94 w.Header().Set("Content-Type", "application/json")
95 w.WriteHeader(http.StatusMethodNotAllowed)
96 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
97 return
98 }
99
100 // Check if auth is enabled
101 if h.authSvc == nil || !h.authSvc.IsEnabled() {
102 w.Header().Set("Content-Type", "application/json")
103 w.WriteHeader(http.StatusServiceUnavailable)
104 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
105 return
106 }
107
108 // Password endpoint only works with password auth type
109 if h.authSvc.GetAuthType() != domain.AuthTypePassword {
110 w.Header().Set("Content-Type", "application/json")
111 w.WriteHeader(http.StatusBadRequest)
112 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
113 Error: "password authentication not configured, use token-based auth",
114 })
115 return
116 }
117
118 // Parse request body
119 var req PasswordRequest
120 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
121 w.Header().Set("Content-Type", "application/json")
122 w.WriteHeader(http.StatusBadRequest)
123 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
124 return
125 }
126
127 if req.Username == "" || req.Password == "" {
128 w.Header().Set("Content-Type", "application/json")
129 w.WriteHeader(http.StatusBadRequest)
130 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
131 return
132 }
133
134 // Validate password
135 if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
136 log.Debug().
137 Str("username", req.Username).
138 Msg("password authentication failed")
139 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
140 w.Header().Set("Content-Type", "application/json")
141 w.WriteHeader(http.StatusUnauthorized)
142 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
143 return
144 }
145
146 // Generate daily session token with full access (push, pull, admin).
147 // This enforces regular re-authentication similar to short-session UX.
148 expiry := passwordSessionTTL
149 scopes := []string{"push", "pull", "admin:*:*"}
150
151 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
152 if err != nil {
153 log.Error().Err(err).Msg("failed to generate token")
154 w.Header().Set("Content-Type", "application/json")
155 w.WriteHeader(http.StatusInternalServerError)
156 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
157 return
158 }
159
160 response := PasswordResponse{
161 Token: token,
162 ExpiresIn: int(expiry.Seconds()),
163 IssuedAt: time.Now().UTC().Format(time.RFC3339),
164 }
165
166 w.Header().Set("Content-Type", "application/json")
167 w.WriteHeader(http.StatusOK)
168
169 if err := json.NewEncoder(w).Encode(response); err != nil {
170 log.Error().Err(err).Msg("failed to encode password response")
171 }
172
173 log.Debug().
174 Str("username", req.Username).
175 Int("expires_in", response.ExpiresIn).
176 Msg("long-lived token issued via password auth")
177}
178
179// handleToken handles GET /auth/token requests.
180// This is the Docker Registry v2 token server endpoint.
181// Issues short-lived access tokens for registry access.
182func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
183 setNoStoreHeaders(w)
184
185 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
186 zerowrap.FieldLayer: "adapter",
187 zerowrap.FieldAdapter: "http",
188 zerowrap.FieldHandler: "auth",
189 zerowrap.FieldMethod: r.Method,
190 zerowrap.FieldPath: r.URL.Path,
191 })
192 log := zerowrap.FromCtx(ctx)
193
194 if r.Method != http.MethodGet {
195 w.Header().Set("Content-Type", "application/json")
196 w.WriteHeader(http.StatusMethodNotAllowed)
197 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
198 return
199 }
200
201 // Check if auth is enabled
202 if h.authSvc == nil || !h.authSvc.IsEnabled() {
203 w.Header().Set("Content-Type", "application/json")
204 w.WriteHeader(http.StatusServiceUnavailable)
205 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
206 return
207 }
208
209 // Get credentials from Basic Auth
210 username, password, ok := r.BasicAuth()
211 if !ok {
212 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
213 w.Header().Set("Content-Type", "application/json")
214 w.WriteHeader(http.StatusUnauthorized)
215 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
216 return
217 }
218
219 authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
220 if !authenticated {
221 log.Debug().
222 Str("username", username).
223 Msg("token request authentication failed")
224 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
225 w.Header().Set("Content-Type", "application/json")
226 w.WriteHeader(http.StatusUnauthorized)
227 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
228 return
229 }
230
231 // Parse requested scopes from query parameter
232 requestedScopes := h.parseRequestedScopes(r, log)
233 if parentClaims != nil {
234 requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
235 if len(requestedScopes) == 0 {
236 w.Header().Set("Content-Type", "application/json")
237 w.WriteHeader(http.StatusForbidden)
238 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
239 return
240 }
241 }
242
243 // Generate a short-lived access token (5 minutes) - not stored
244 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
245 if err != nil {
246 log.Error().Err(err).Msg("failed to generate access token")
247 w.Header().Set("Content-Type", "application/json")
248 w.WriteHeader(http.StatusInternalServerError)
249 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
250 return
251 }
252
253 response := dto.TokenResponse{
254 Token: accessToken,
255 ExpiresIn: 300, // 5 minutes in seconds
256 IssuedAt: time.Now().UTC().Format(time.RFC3339),
257 }
258
259 w.Header().Set("Content-Type", "application/json")
260 w.WriteHeader(http.StatusOK)
261
262 if err := json.NewEncoder(w).Encode(response); err != nil {
263 log.Error().Err(err).Msg("failed to encode token response")
264 }
265
266 log.Debug().
267 Str("username", username).
268 Int("expires_in", response.ExpiresIn).
269 Msg("access token issued")
270}
271
272func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
273 if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
274 log.Debug().Str("username", username).Msg("internal registry auth accepted")
275 return true, nil
276 }
277
278 // Always try JWT token validation first — even in password-auth mode the
279 // server issues JWTs on login, and the CLI sends them back via Basic Auth
280 // for registry token exchange.
281 claims, err := h.authSvc.ValidateToken(ctx, password)
282 if err == nil && claims.Subject == username {
283 return true, claims
284 }
285
286 // Fall back to password validation for direct username/password credentials.
287 if h.authSvc.GetAuthType() == domain.AuthTypePassword {
288 if h.authSvc.ValidatePassword(ctx, username, password) {
289 return true, nil
290 }
291 }
292
293 return false, nil
294}
295
296func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
297 var effective []string
298
299 for _, reqScopeStr := range requestedScopes {
300 reqScope, err := domain.ParseScope(reqScopeStr)
301 if err != nil || reqScope.Type != domain.ScopeTypeRepository {
302 continue
303 }
304
305 allowedActions := make([]string, 0, len(reqScope.Actions))
306 for _, action := range reqScope.Actions {
307 if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
308 allowedActions = append(allowedActions, action)
309 }
310 }
311
312 if len(allowedActions) == 0 {
313 continue
314 }
315
316 effective = append(effective, (&domain.Scope{
317 Type: reqScope.Type,
318 Name: reqScope.Name,
319 Actions: allowedActions,
320 }).String())
321 }
322
323 log.Debug().
324 Strs("requested_scopes", requestedScopes).
325 Strs("granted_scopes", grantedScopes).
326 Strs("effective_scopes", effective).
327 Msg("calculated effective scopes from parent token")
328
329 return effective
330}
331
332func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
333 for _, grantedScopeStr := range grantedScopes {
334 scopeStr := strings.TrimSpace(grantedScopeStr)
335 switch scopeStr {
336 case domain.ScopeActionAll:
337 return true
338 case domain.ScopeActionPull, domain.ScopeActionPush:
339 if scopeStr == action {
340 return true
341 }
342 }
343
344 scope, err := domain.ParseScope(scopeStr)
345 if err != nil {
346 continue
347 }
348 if scope.Type != domain.ScopeTypeRepository {
349 continue
350 }
351 if scope.CanAccess(repoName, action) {
352 return true
353 }
354 }
355
356 return false
357}
358
359// parseRequestedScopes extracts and validates scope parameters from the request.
360// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
361// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
362func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
363 scopeParams := r.URL.Query()["scope"]
364
365 if len(scopeParams) == 0 {
366 // Default scope: pull from any repository for backward compatibility
367 log.Debug().Msg("no scope requested, using default pull scope")
368 return []string{"repository:*:pull"}
369 }
370
371 var validScopes []string
372 for _, scopeStr := range scopeParams {
373 // Validate the scope format
374 scope, err := domain.ParseScope(scopeStr)
375 if err != nil {
376 log.Debug().
377 Err(err).
378 Str("scope", scopeStr).
379 Msg("invalid scope format, skipping")
380 continue
381 }
382
383 // Only allow repository scopes
384 if scope.Type != "repository" {
385 log.Debug().
386 Str("scope_type", scope.Type).
387 Str("scope", scopeStr).
388 Msg("unsupported scope type, skipping")
389 continue
390 }
391
392 validScopes = append(validScopes, scopeStr)
393 }
394
395 if len(validScopes) == 0 {
396 // All requested scopes were invalid, return default
397 log.Debug().Msg("all requested scopes invalid, using default pull scope")
398 return []string{"repository:*:pull"}
399 }
400
401 log.Debug().
402 Strs("scopes", validScopes).
403 Msg("parsed requested scopes")
404 return validScopes
405}
406
407// isInternalAuth checks if the credentials match internal registry auth.
408func (h *Handler) isInternalAuth(username, password string) bool {
409 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
410 return false
411 }
412 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
413 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
414 return usernameMatch && passwordMatch
415}