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
42func setNoStoreHeaders(w http.ResponseWriter) {
43 w.Header().Set("Cache-Control", "no-store")
44 w.Header().Set("Pragma", "no-cache")
45}
46
47// ServeHTTP routes requests to the appropriate handler.
48func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
49 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
50
51 switch path {
52 case "/password":
53 h.handlePassword(w, r)
54 case "/token":
55 h.handleToken(w, r)
56 default:
57 w.Header().Set("Content-Type", "application/json")
58 w.WriteHeader(http.StatusNotFound)
59 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
60 }
61}
62
63// handlePassword handles POST /auth/password requests.
64// Password authentication has been removed; this endpoint always returns Gone.
65func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
66 setNoStoreHeaders(w)
67 w.Header().Set("Content-Type", "application/json")
68 w.WriteHeader(http.StatusGone)
69 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
70 Error: "password authentication has been removed, use token-based auth",
71 })
72}
73
74// handleToken handles GET /auth/token requests.
75// This is the Docker Registry v2 token server endpoint.
76// Issues short-lived access tokens for registry access.
77func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
78 setNoStoreHeaders(w)
79
80 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
81 zerowrap.FieldLayer: "adapter",
82 zerowrap.FieldAdapter: "http",
83 zerowrap.FieldHandler: "auth",
84 zerowrap.FieldMethod: r.Method,
85 zerowrap.FieldPath: r.URL.Path,
86 })
87 log := zerowrap.FromCtx(ctx)
88
89 if r.Method != http.MethodGet {
90 w.Header().Set("Content-Type", "application/json")
91 w.WriteHeader(http.StatusMethodNotAllowed)
92 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
93 return
94 }
95
96 // Check if auth is enabled
97 if h.authSvc == nil || !h.authSvc.IsEnabled() {
98 w.Header().Set("Content-Type", "application/json")
99 w.WriteHeader(http.StatusServiceUnavailable)
100 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
101 return
102 }
103
104 // Get credentials from Basic Auth
105 username, password, ok := r.BasicAuth()
106 if !ok {
107 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
108 w.Header().Set("Content-Type", "application/json")
109 w.WriteHeader(http.StatusUnauthorized)
110 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
111 return
112 }
113
114 authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
115 if !authenticated {
116 log.Debug().
117 Str("username", username).
118 Msg("token request authentication failed")
119 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
120 w.Header().Set("Content-Type", "application/json")
121 w.WriteHeader(http.StatusUnauthorized)
122 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
123 return
124 }
125
126 // Parse requested scopes from query parameter
127 requestedScopes := h.parseRequestedScopes(r, log)
128 if parentClaims != nil {
129 requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
130 if len(requestedScopes) == 0 {
131 w.Header().Set("Content-Type", "application/json")
132 w.WriteHeader(http.StatusForbidden)
133 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
134 return
135 }
136 }
137
138 // Generate a short-lived access token with configurable TTL - not stored
139 ttl := h.authSvc.GetAccessTokenTTL()
140 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
141 if err != nil {
142 log.Error().Err(err).Msg("failed to generate access token")
143 w.Header().Set("Content-Type", "application/json")
144 w.WriteHeader(http.StatusInternalServerError)
145 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
146 return
147 }
148
149 response := dto.TokenResponse{
150 Token: accessToken,
151 ExpiresIn: int(ttl.Seconds()),
152 IssuedAt: time.Now().UTC().Format(time.RFC3339),
153 }
154
155 w.Header().Set("Content-Type", "application/json")
156 w.WriteHeader(http.StatusOK)
157
158 if err := json.NewEncoder(w).Encode(response); err != nil {
159 log.Error().Err(err).Msg("failed to encode token response")
160 }
161
162 log.Debug().
163 Str("username", username).
164 Int("expires_in", response.ExpiresIn).
165 Msg("access token issued")
166}
167
168func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
169 if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
170 log.Debug().Str("username", username).Msg("internal registry auth accepted")
171 return true, &domain.TokenClaims{
172 Subject: username,
173 Scopes: []string{"push", "pull"},
174 }
175 }
176
177 // Validate JWT token sent via Basic Auth password field.
178 claims, err := h.authSvc.ValidateToken(ctx, password)
179 if err == nil && claims.Subject == username {
180 if claims.IsEphemeral {
181 log.Debug().Msg("rejected ephemeral token as exchange credential")
182 return false, nil
183 }
184 return true, claims
185 }
186
187 return false, nil
188}
189
190func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
191 var effective []string
192
193 for _, reqScopeStr := range requestedScopes {
194 reqScope, err := domain.ParseScope(reqScopeStr)
195 if err != nil {
196 continue
197 }
198
199 var checkAccess func([]string, string, string) bool
200 switch reqScope.Type {
201 case domain.ScopeTypeRepository:
202 checkAccess = domain.ScopesGrantRegistryAccess
203 case domain.ScopeTypeAdmin:
204 checkAccess = domain.ScopesGrantAdminAccess
205 default:
206 continue
207 }
208
209 if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" {
210 effective = append(effective, s)
211 }
212 }
213
214 log.Debug().
215 Strs("requested_scopes", requestedScopes).
216 Strs("granted_scopes", grantedScopes).
217 Strs("effective_scopes", effective).
218 Msg("calculated effective scopes from parent token")
219
220 return effective
221}
222
223// buildEffectiveScope filters a requested scope's actions through checkAccess and returns
224// the resulting scope string, or "" if no actions were granted.
225func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string {
226 allowedActions := make([]string, 0, len(reqScope.Actions))
227 for _, action := range reqScope.Actions {
228 if checkAccess(grantedScopes, reqScope.Name, action) {
229 allowedActions = append(allowedActions, action)
230 }
231 }
232 if len(allowedActions) == 0 {
233 return ""
234 }
235 return (&domain.Scope{
236 Type: reqScope.Type,
237 Name: reqScope.Name,
238 Actions: allowedActions,
239 }).String()
240}
241
242// parseRequestedScopes extracts and validates scope parameters from the request.
243// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
244// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
245func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
246 scopeParams := r.URL.Query()["scope"]
247
248 if len(scopeParams) == 0 {
249 // Default scope: pull from any repository for backward compatibility
250 log.Debug().Msg("no scope requested, using default pull scope")
251 return []string{"repository:*:pull"}
252 }
253
254 var validScopes []string
255 for _, scopeStr := range scopeParams {
256 // Validate the scope format
257 scope, err := domain.ParseScope(scopeStr)
258 if err != nil {
259 log.Debug().
260 Err(err).
261 Str("scope", scopeStr).
262 Msg("invalid scope format, skipping")
263 continue
264 }
265
266 // Allow repository and admin scopes
267 if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
268 log.Debug().
269 Str("scope_type", scope.Type).
270 Str("scope", scopeStr).
271 Msg("unsupported scope type, skipping")
272 continue
273 }
274
275 validScopes = append(validScopes, scopeStr)
276 }
277
278 if len(validScopes) == 0 {
279 // All requested scopes were invalid, return default
280 log.Debug().Msg("all requested scopes invalid, using default pull scope")
281 return []string{"repository:*:pull"}
282 }
283
284 log.Debug().
285 Strs("scopes", validScopes).
286 Msg("parsed requested scopes")
287 return validScopes
288}
289
290// isInternalAuth checks if the credentials match internal registry auth.
291func (h *Handler) isInternalAuth(username, password string) bool {
292 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
293 return false
294 }
295 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
296 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
297 return usernameMatch && passwordMatch
298}