1package registry
2
3import (
4 "encoding/json"
5 "net/http"
6 "strings"
7 "time"
8
9 "github.com/bnema/zerowrap"
10
11 "gordon/internal/boundaries/in"
12 "gordon/internal/domain"
13)
14
15// TokenResponse represents the response from the token server.
16type TokenResponse struct {
17 Token string `json:"token"`
18 AccessToken string `json:"access_token,omitempty"`
19 ExpiresIn int `json:"expires_in,omitempty"`
20 IssuedAt string `json:"issued_at,omitempty"`
21}
22
23// InternalAuth holds credentials for internal loopback registry access.
24type InternalAuth struct {
25 Username string
26 Password string
27}
28
29// TokenHandler handles token server requests for Docker Registry authentication.
30type TokenHandler struct {
31 authSvc in.AuthService
32 internalAuth InternalAuth
33 log zerowrap.Logger
34}
35
36// NewTokenHandler creates a new token handler.
37func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler {
38 return &TokenHandler{
39 authSvc: authSvc,
40 internalAuth: internalAuth,
41 log: log,
42 }
43}
44
45// ServeHTTP implements http.Handler for the token endpoint.
46func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
47 h.handleToken(w, r)
48}
49
50// handleToken handles GET /v2/token requests.
51// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
52func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
53 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
54 zerowrap.FieldLayer: "adapter",
55 zerowrap.FieldAdapter: "http",
56 zerowrap.FieldHandler: "token",
57 zerowrap.FieldMethod: r.Method,
58 zerowrap.FieldPath: r.URL.Path,
59 })
60 log := zerowrap.FromCtx(ctx)
61
62 if r.Method != http.MethodGet {
63 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
64 return
65 }
66
67 // Check if auth is enabled
68 if h.authSvc == nil || !h.authSvc.IsEnabled() {
69 // If auth is disabled, return an anonymous token
70 h.sendAnonymousToken(w, log)
71 return
72 }
73
74 // Get credentials from Basic Auth
75 username, password, ok := r.BasicAuth()
76 if !ok {
77 // No credentials provided - return anonymous token with limited scope
78 h.sendAnonymousToken(w, log)
79 return
80 }
81
82 // Check for internal registry auth (localhost requests with internal creds)
83 var authenticated bool
84 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
85 authenticated = true
86 log.Debug().Str("username", username).Msg("internal registry auth accepted")
87 } else {
88 // Validate credentials based on auth type
89 switch h.authSvc.GetAuthType() {
90 case domain.AuthTypePassword:
91 authenticated = h.authSvc.ValidatePassword(ctx, username, password)
92 case domain.AuthTypeToken:
93 // For token auth, the password might be an existing JWT
94 claims, err := h.authSvc.ValidateToken(ctx, password)
95 if err == nil && claims.Subject == username {
96 authenticated = true
97 }
98 }
99 }
100
101 if !authenticated {
102 log.Debug().
103 Str("username", username).
104 Msg("token request authentication failed")
105 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
106 http.Error(w, "Unauthorized", http.StatusUnauthorized)
107 return
108 }
109
110 // Parse requested scopes from query parameter
111 // Format: scope=repository:name:action1,action2 (can appear multiple times)
112 requestedScopes := h.parseRequestedScopes(r, log)
113
114 // Generate a short-lived access token (5 minutes) - not stored
115 accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
116 if err != nil {
117 log.Error().Err(err).Msg("failed to generate access token")
118 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
119 return
120 }
121
122 response := TokenResponse{
123 Token: accessToken,
124 ExpiresIn: 300, // 5 minutes in seconds
125 IssuedAt: time.Now().Format(time.RFC3339),
126 }
127
128 w.Header().Set("Content-Type", "application/json")
129 w.WriteHeader(http.StatusOK)
130
131 if err := json.NewEncoder(w).Encode(response); err != nil {
132 log.Error().Err(err).Msg("failed to encode token response")
133 }
134
135 log.Debug().
136 Str("username", username).
137 Int("expires_in", response.ExpiresIn).
138 Msg("access token issued")
139}
140
141// sendAnonymousToken sends a token for anonymous/unauthenticated access.
142func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
143 // For anonymous access, we issue a very short-lived token with limited scope
144 response := TokenResponse{
145 Token: "", // Empty token indicates limited access
146 ExpiresIn: 60, // 1 minute
147 IssuedAt: time.Now().Format(time.RFC3339),
148 }
149
150 w.Header().Set("Content-Type", "application/json")
151 w.WriteHeader(http.StatusOK)
152
153 if err := json.NewEncoder(w).Encode(response); err != nil {
154 log.Error().Err(err).Msg("failed to encode anonymous token response")
155 }
156
157 log.Debug().Msg("anonymous token issued")
158}
159
160// isLocalhostRequest checks if the request originates from localhost.
161// SECURITY: Uses RemoteAddr (server-set) instead of Host header (client-spoofable).
162func isLocalhostRequest(r *http.Request) bool {
163 host := r.RemoteAddr
164 // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
165 return strings.HasPrefix(host, "127.") ||
166 strings.HasPrefix(host, "[::1]") ||
167 strings.HasPrefix(host, "::1")
168}
169
170// parseRequestedScopes extracts and validates scope parameters from the request.
171// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
172// Example: GET /v2/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
173func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
174 scopeParams := r.URL.Query()["scope"]
175
176 if len(scopeParams) == 0 {
177 // Default scope: pull from any repository for backward compatibility
178 log.Debug().Msg("no scope requested, using default pull scope")
179 return []string{"repository:*:pull"}
180 }
181
182 var validScopes []string
183 for _, scopeStr := range scopeParams {
184 // Validate the scope format
185 scope, err := domain.ParseScope(scopeStr)
186 if err != nil {
187 log.Debug().
188 Err(err).
189 Str("scope", scopeStr).
190 Msg("invalid scope format, skipping")
191 continue
192 }
193
194 // Only allow repository scopes
195 if scope.Type != "repository" {
196 log.Debug().
197 Str("scope_type", scope.Type).
198 Str("scope", scopeStr).
199 Msg("unsupported scope type, skipping")
200 continue
201 }
202
203 validScopes = append(validScopes, scopeStr)
204 }
205
206 if len(validScopes) == 0 {
207 // All requested scopes were invalid, return default
208 log.Debug().Msg("all requested scopes invalid, using default pull scope")
209 return []string{"repository:*:pull"}
210 }
211
212 log.Debug().
213 Strs("scopes", validScopes).
214 Msg("parsed requested scopes")
215 return validScopes
216}
217
218// isInternalAuth checks if the credentials match internal registry auth.
219func (h *TokenHandler) isInternalAuth(username, password string) bool {
220 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
221 return false
222 }
223 return username == h.internalAuth.Username && password == h.internalAuth.Password
224}