v2.2.7 aefe19e

token.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close