1package registry
2
3import (
4 "encoding/json"
5 "net/http"
6 "time"
7
8 "github.com/bnema/zerowrap"
9
10 "gordon/internal/boundaries/in"
11 "gordon/internal/domain"
12)
13
14// TokenResponse represents the response from the token server.
15type TokenResponse struct {
16 Token string `json:"token"`
17 AccessToken string `json:"access_token,omitempty"`
18 ExpiresIn int `json:"expires_in,omitempty"`
19 IssuedAt string `json:"issued_at,omitempty"`
20}
21
22// InternalAuth holds credentials for internal loopback registry access.
23type InternalAuth struct {
24 Username string
25 Password string
26}
27
28// TokenHandler handles token server requests for Docker Registry authentication.
29type TokenHandler struct {
30 authSvc in.AuthService
31 internalAuth InternalAuth
32 log zerowrap.Logger
33}
34
35// NewTokenHandler creates a new token handler.
36func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler {
37 return &TokenHandler{
38 authSvc: authSvc,
39 internalAuth: internalAuth,
40 log: log,
41 }
42}
43
44// ServeHTTP implements http.Handler for the token endpoint.
45func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
46 h.handleToken(w, r)
47}
48
49// handleToken handles GET /v2/token requests.
50// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
51func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
52 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
53 zerowrap.FieldLayer: "adapter",
54 zerowrap.FieldAdapter: "http",
55 zerowrap.FieldHandler: "token",
56 zerowrap.FieldMethod: r.Method,
57 zerowrap.FieldPath: r.URL.Path,
58 })
59 log := zerowrap.FromCtx(ctx)
60
61 if r.Method != http.MethodGet {
62 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
63 return
64 }
65
66 // Check if auth is enabled
67 if h.authSvc == nil || !h.authSvc.IsEnabled() {
68 // If auth is disabled, return an anonymous token
69 h.sendAnonymousToken(w, log)
70 return
71 }
72
73 // Get credentials from Basic Auth
74 username, password, ok := r.BasicAuth()
75 if !ok {
76 // No credentials provided - return anonymous token with limited scope
77 h.sendAnonymousToken(w, log)
78 return
79 }
80
81 // Check for internal registry auth (localhost requests with internal creds)
82 var authenticated bool
83 if isLocalhostRequest(r) && h.isInternalAuth(username, password) {
84 authenticated = true
85 log.Debug().Str("username", username).Msg("internal registry auth accepted")
86 } else {
87 // Validate credentials based on auth type
88 switch h.authSvc.GetAuthType() {
89 case domain.AuthTypePassword:
90 authenticated = h.authSvc.ValidatePassword(ctx, username, password)
91 case domain.AuthTypeToken:
92 // For token auth, the password might be an existing JWT
93 claims, err := h.authSvc.ValidateToken(ctx, password)
94 if err == nil && claims.Subject == username {
95 authenticated = true
96 }
97 }
98 }
99
100 if !authenticated {
101 log.Debug().
102 Str("username", username).
103 Msg("token request authentication failed")
104 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
105 http.Error(w, "Unauthorized", http.StatusUnauthorized)
106 return
107 }
108
109 // Generate a short-lived access token (5 minutes)
110 accessToken, err := h.authSvc.GenerateToken(ctx, username, []string{"push", "pull"}, 5*time.Minute)
111 if err != nil {
112 log.Error().Err(err).Msg("failed to generate access token")
113 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
114 return
115 }
116
117 response := TokenResponse{
118 Token: accessToken,
119 ExpiresIn: 300, // 5 minutes in seconds
120 IssuedAt: time.Now().Format(time.RFC3339),
121 }
122
123 w.Header().Set("Content-Type", "application/json")
124 w.WriteHeader(http.StatusOK)
125
126 if err := json.NewEncoder(w).Encode(response); err != nil {
127 log.Error().Err(err).Msg("failed to encode token response")
128 }
129
130 log.Debug().
131 Str("username", username).
132 Int("expires_in", response.ExpiresIn).
133 Msg("access token issued")
134}
135
136// sendAnonymousToken sends a token for anonymous/unauthenticated access.
137func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
138 // For anonymous access, we issue a very short-lived token with limited scope
139 response := TokenResponse{
140 Token: "", // Empty token indicates limited access
141 ExpiresIn: 60, // 1 minute
142 IssuedAt: time.Now().Format(time.RFC3339),
143 }
144
145 w.Header().Set("Content-Type", "application/json")
146 w.WriteHeader(http.StatusOK)
147
148 if err := json.NewEncoder(w).Encode(response); err != nil {
149 log.Error().Err(err).Msg("failed to encode anonymous token response")
150 }
151
152 log.Debug().Msg("anonymous token issued")
153}
154
155// isLocalhostRequest checks if the request is from localhost.
156func isLocalhostRequest(r *http.Request) bool {
157 host := r.Host
158 if host == "" {
159 host = r.URL.Host
160 }
161 return host == "localhost:5000" || host == "127.0.0.1:5000" || host == "[::1]:5000"
162}
163
164// isInternalAuth checks if the credentials match internal registry auth.
165func (h *TokenHandler) isInternalAuth(username, password string) bool {
166 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
167 return false
168 }
169 return username == h.internalAuth.Username && password == h.internalAuth.Password
170}