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