1// Package auth implements the HTTP adapter for authentication endpoints.
2package auth
3
4import (
5 "crypto/subtle"
6 "encoding/json"
7 "net/http"
8 "strings"
9 "time"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/adapters/dto"
14 "github.com/bnema/gordon/internal/boundaries/in"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18// InternalAuth holds credentials for internal loopback registry access.
19type InternalAuth struct {
20 Username string
21 Password string
22}
23
24// Handler handles authentication requests at /auth/*.
25type Handler struct {
26 authSvc in.AuthService
27 internalAuth InternalAuth
28 log zerowrap.Logger
29}
30
31// NewHandler creates a new auth handler.
32func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
33 return &Handler{
34 authSvc: authSvc,
35 internalAuth: internalAuth,
36 log: log,
37 }
38}
39
40// PasswordRequest represents the request body for POST /auth/password.
41type PasswordRequest struct {
42 Username string `json:"username"`
43 Password string `json:"password"`
44}
45
46// PasswordResponse represents the response from POST /auth/password.
47type PasswordResponse struct {
48 Token string `json:"token"`
49 ExpiresIn int `json:"expires_in"`
50 IssuedAt string `json:"issued_at"`
51}
52
53// ServeHTTP routes requests to the appropriate handler.
54func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
55 path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
56
57 switch path {
58 case "/password":
59 h.handlePassword(w, r)
60 case "/token":
61 h.handleToken(w, r)
62 default:
63 w.Header().Set("Content-Type", "application/json")
64 w.WriteHeader(http.StatusNotFound)
65 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
66 }
67}
68
69// handlePassword handles POST /auth/password requests.
70// Validates username/password and returns a long-lived JWT (7 days).
71// Only works when auth type is "password" - returns error for "token" auth type.
72func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
73 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
74 zerowrap.FieldLayer: "adapter",
75 zerowrap.FieldAdapter: "http",
76 zerowrap.FieldHandler: "auth",
77 zerowrap.FieldMethod: r.Method,
78 zerowrap.FieldPath: r.URL.Path,
79 })
80 log := zerowrap.FromCtx(ctx)
81
82 if r.Method != http.MethodPost {
83 w.Header().Set("Content-Type", "application/json")
84 w.WriteHeader(http.StatusMethodNotAllowed)
85 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
86 return
87 }
88
89 // Check if auth is enabled
90 if h.authSvc == nil || !h.authSvc.IsEnabled() {
91 w.Header().Set("Content-Type", "application/json")
92 w.WriteHeader(http.StatusBadRequest)
93 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})
94 return
95 }
96
97 // Password endpoint only works with password auth type
98 if h.authSvc.GetAuthType() != domain.AuthTypePassword {
99 w.Header().Set("Content-Type", "application/json")
100 w.WriteHeader(http.StatusBadRequest)
101 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
102 Error: "password authentication not configured, use token-based auth",
103 })
104 return
105 }
106
107 // Parse request body
108 var req PasswordRequest
109 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
110 w.Header().Set("Content-Type", "application/json")
111 w.WriteHeader(http.StatusBadRequest)
112 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
113 return
114 }
115
116 if req.Username == "" || req.Password == "" {
117 w.Header().Set("Content-Type", "application/json")
118 w.WriteHeader(http.StatusBadRequest)
119 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
120 return
121 }
122
123 // Validate password
124 if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
125 log.Debug().
126 Str("username", req.Username).
127 Msg("password authentication failed")
128 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
129 w.Header().Set("Content-Type", "application/json")
130 w.WriteHeader(http.StatusUnauthorized)
131 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
132 return
133 }
134
135 // Generate 7-day token with full access (push, pull, admin)
136 expiry := 7 * 24 * time.Hour
137 scopes := []string{"push", "pull", "admin:*:*"}
138
139 token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
140 if err != nil {
141 log.Error().Err(err).Msg("failed to generate token")
142 w.Header().Set("Content-Type", "application/json")
143 w.WriteHeader(http.StatusInternalServerError)
144 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
145 return
146 }
147
148 response := PasswordResponse{
149 Token: token,
150 ExpiresIn: int(expiry.Seconds()),
151 IssuedAt: time.Now().UTC().Format(time.RFC3339),
152 }
153
154 w.Header().Set("Content-Type", "application/json")
155 w.WriteHeader(http.StatusOK)
156
157 if err := json.NewEncoder(w).Encode(response); err != nil {
158 log.Error().Err(err).Msg("failed to encode password response")
159 }
160
161 log.Debug().
162 Str("username", req.Username).
163 Int("expires_in", response.ExpiresIn).
164 Msg("long-lived token issued via password auth")
165}
166
167// handleToken handles GET /auth/token requests.
168// This is the Docker Registry v2 token server endpoint.
169// Issues short-lived access tokens for registry access.
170func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
171 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
172 zerowrap.FieldLayer: "adapter",
173 zerowrap.FieldAdapter: "http",
174 zerowrap.FieldHandler: "auth",
175 zerowrap.FieldMethod: r.Method,
176 zerowrap.FieldPath: r.URL.Path,
177 })
178 log := zerowrap.FromCtx(ctx)
179
180 if r.Method != http.MethodGet {
181 w.Header().Set("Content-Type", "application/json")
182 w.WriteHeader(http.StatusMethodNotAllowed)
183 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
184 return
185 }
186
187 // Check if auth is enabled
188 if h.authSvc == nil || !h.authSvc.IsEnabled() {
189 // If auth is disabled, return an anonymous token
190 h.sendAnonymousToken(w, log)
191 return
192 }
193
194 // Get credentials from Basic Auth
195 username, password, ok := r.BasicAuth()
196 if !ok {
197 // No credentials provided - return anonymous token with limited scope
198 h.sendAnonymousToken(w, log)
199 return
200 }
201
202 // Check for internal registry auth (localhost requests with internal creds)
203 var authenticated bool
204 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
205 authenticated = true
206 log.Debug().Str("username", username).Msg("internal registry auth accepted")
207 } else {
208 // Validate credentials based on auth type
209 switch h.authSvc.GetAuthType() {
210 case domain.AuthTypePassword:
211 authenticated = h.authSvc.ValidatePassword(ctx, username, password)
212 case domain.AuthTypeToken:
213 // For token auth, the password might be an existing JWT
214 claims, err := h.authSvc.ValidateToken(ctx, password)
215 if err == nil && claims.Subject == username {
216 authenticated = true
217 }
218 }
219 }
220
221 if !authenticated {
222 log.Debug().
223 Str("username", username).
224 Msg("token request authentication failed")
225 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
226 w.Header().Set("Content-Type", "application/json")
227 w.WriteHeader(http.StatusUnauthorized)
228 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
229 return
230 }
231
232 // Parse requested scopes from query parameter
233 requestedScopes := h.parseRequestedScopes(r, log)
234
235 // Generate a short-lived access token (5 minutes) - not stored
236 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
237 if err != nil {
238 log.Error().Err(err).Msg("failed to generate access token")
239 w.Header().Set("Content-Type", "application/json")
240 w.WriteHeader(http.StatusInternalServerError)
241 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
242 return
243 }
244
245 response := dto.TokenResponse{
246 Token: accessToken,
247 ExpiresIn: 300, // 5 minutes in seconds
248 IssuedAt: time.Now().UTC().Format(time.RFC3339),
249 }
250
251 w.Header().Set("Content-Type", "application/json")
252 w.WriteHeader(http.StatusOK)
253
254 if err := json.NewEncoder(w).Encode(response); err != nil {
255 log.Error().Err(err).Msg("failed to encode token response")
256 }
257
258 log.Debug().
259 Str("username", username).
260 Int("expires_in", response.ExpiresIn).
261 Msg("access token issued")
262}
263
264// sendAnonymousToken sends a token for anonymous/unauthenticated access.
265func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
266 // For anonymous access, we issue a very short-lived token with limited scope
267 response := dto.TokenResponse{
268 Token: "", // Empty token indicates limited access
269 ExpiresIn: 60, // 1 minute
270 IssuedAt: time.Now().UTC().Format(time.RFC3339),
271 }
272
273 w.Header().Set("Content-Type", "application/json")
274 w.WriteHeader(http.StatusOK)
275
276 if err := json.NewEncoder(w).Encode(response); err != nil {
277 log.Error().Err(err).Msg("failed to encode anonymous token response")
278 }
279
280 log.Debug().Msg("anonymous token issued")
281}
282
283// isLocalhostRequest checks if the request originates from localhost.
284// SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
285func isLocalhostRequest(r *http.Request) bool {
286 host := r.RemoteAddr
287 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
288 return strings.HasPrefix(host, "127.") ||
289 strings.HasPrefix(host, "[::1]") ||
290 strings.HasPrefix(host, "::1")
291}
292
293// parseRequestedScopes extracts and validates scope parameters from the request.
294// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
295// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
296func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
297 scopeParams := r.URL.Query()["scope"]
298
299 if len(scopeParams) == 0 {
300 // Default scope: pull from any repository for backward compatibility
301 log.Debug().Msg("no scope requested, using default pull scope")
302 return []string{"repository:*:pull"}
303 }
304
305 var validScopes []string
306 for _, scopeStr := range scopeParams {
307 // Validate the scope format
308 scope, err := domain.ParseScope(scopeStr)
309 if err != nil {
310 log.Debug().
311 Err(err).
312 Str("scope", scopeStr).
313 Msg("invalid scope format, skipping")
314 continue
315 }
316
317 // Only allow repository scopes
318 if scope.Type != "repository" {
319 log.Debug().
320 Str("scope_type", scope.Type).
321 Str("scope", scopeStr).
322 Msg("unsupported scope type, skipping")
323 continue
324 }
325
326 validScopes = append(validScopes, scopeStr)
327 }
328
329 if len(validScopes) == 0 {
330 // All requested scopes were invalid, return default
331 log.Debug().Msg("all requested scopes invalid, using default pull scope")
332 return []string{"repository:*:pull"}
333 }
334
335 log.Debug().
336 Strs("scopes", validScopes).
337 Msg("parsed requested scopes")
338 return validScopes
339}
340
341// isInternalAuth checks if the credentials match internal registry auth.
342func (h *Handler) isInternalAuth(username, password string) bool {
343 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
344 return false
345 }
346 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
347 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
348 return usernameMatch && passwordMatch
349}