v2.30.0-pre 7341e0e

handler.go RawHistoryFolder

1// Package auth implements the HTTP adapter for authentication endpoints.
2package auth
3
4import (
5	"context"
6	"crypto/subtle"
7	"encoding/json"
8	"net/http"
9	"strings"
10	"time"
11
12	"github.com/bnema/zerowrap"
13
14	"github.com/bnema/gordon/internal/adapters/dto"
15	"github.com/bnema/gordon/internal/adapters/in/http/httputil"
16	"github.com/bnema/gordon/internal/boundaries/in"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// InternalAuth holds credentials for internal loopback registry access.
21type InternalAuth struct {
22	Username string
23	Password string
24}
25
26// Handler handles authentication requests at /auth/*.
27type Handler struct {
28	authSvc      in.AuthService
29	internalAuth InternalAuth
30	log          zerowrap.Logger
31}
32
33// NewHandler creates a new auth handler.
34func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
35	return &Handler{
36		authSvc:      authSvc,
37		internalAuth: internalAuth,
38		log:          log,
39	}
40}
41
42// PasswordRequest represents the request body for POST /auth/password.
43type PasswordRequest struct {
44	Username string `json:"username"`
45	Password string `json:"password"`
46}
47
48// PasswordResponse represents the response from POST /auth/password.
49type PasswordResponse struct {
50	Token     string `json:"token"`
51	ExpiresIn int    `json:"expires_in"`
52	IssuedAt  string `json:"issued_at"`
53}
54
55const passwordSessionTTL = 24 * time.Hour
56
57func setNoStoreHeaders(w http.ResponseWriter) {
58	w.Header().Set("Cache-Control", "no-store")
59	w.Header().Set("Pragma", "no-cache")
60}
61
62// ServeHTTP routes requests to the appropriate handler.
63func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
64	path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
65
66	switch path {
67	case "/password":
68		h.handlePassword(w, r)
69	case "/token":
70		h.handleToken(w, r)
71	default:
72		w.Header().Set("Content-Type", "application/json")
73		w.WriteHeader(http.StatusNotFound)
74		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
75	}
76}
77
78// handlePassword handles POST /auth/password requests.
79// Validates username/password and returns a daily session JWT.
80// Only works when auth type is "password" - returns error for "token" auth type.
81func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
82	setNoStoreHeaders(w)
83
84	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85		zerowrap.FieldLayer:   "adapter",
86		zerowrap.FieldAdapter: "http",
87		zerowrap.FieldHandler: "auth",
88		zerowrap.FieldMethod:  r.Method,
89		zerowrap.FieldPath:    r.URL.Path,
90	})
91	log := zerowrap.FromCtx(ctx)
92
93	if r.Method != http.MethodPost {
94		w.Header().Set("Content-Type", "application/json")
95		w.WriteHeader(http.StatusMethodNotAllowed)
96		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
97		return
98	}
99
100	// Check if auth is enabled
101	if h.authSvc == nil || !h.authSvc.IsEnabled() {
102		w.Header().Set("Content-Type", "application/json")
103		w.WriteHeader(http.StatusServiceUnavailable)
104		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
105		return
106	}
107
108	// Password endpoint only works with password auth type
109	if h.authSvc.GetAuthType() != domain.AuthTypePassword {
110		w.Header().Set("Content-Type", "application/json")
111		w.WriteHeader(http.StatusBadRequest)
112		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{
113			Error: "password authentication not configured, use token-based auth",
114		})
115		return
116	}
117
118	// Parse request body
119	var req PasswordRequest
120	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
121		w.Header().Set("Content-Type", "application/json")
122		w.WriteHeader(http.StatusBadRequest)
123		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
124		return
125	}
126
127	if req.Username == "" || req.Password == "" {
128		w.Header().Set("Content-Type", "application/json")
129		w.WriteHeader(http.StatusBadRequest)
130		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
131		return
132	}
133
134	// Validate password
135	if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
136		log.Debug().
137			Str("username", req.Username).
138			Msg("password authentication failed")
139		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
140		w.Header().Set("Content-Type", "application/json")
141		w.WriteHeader(http.StatusUnauthorized)
142		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
143		return
144	}
145
146	// Generate daily session token with full access (push, pull, admin).
147	// This enforces regular re-authentication similar to short-session UX.
148	expiry := passwordSessionTTL
149	scopes := []string{"push", "pull", "admin:*:*"}
150
151	token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
152	if err != nil {
153		log.Error().Err(err).Msg("failed to generate token")
154		w.Header().Set("Content-Type", "application/json")
155		w.WriteHeader(http.StatusInternalServerError)
156		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
157		return
158	}
159
160	response := PasswordResponse{
161		Token:     token,
162		ExpiresIn: int(expiry.Seconds()),
163		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
164	}
165
166	w.Header().Set("Content-Type", "application/json")
167	w.WriteHeader(http.StatusOK)
168
169	if err := json.NewEncoder(w).Encode(response); err != nil {
170		log.Error().Err(err).Msg("failed to encode password response")
171	}
172
173	log.Debug().
174		Str("username", req.Username).
175		Int("expires_in", response.ExpiresIn).
176		Msg("long-lived token issued via password auth")
177}
178
179// handleToken handles GET /auth/token requests.
180// This is the Docker Registry v2 token server endpoint.
181// Issues short-lived access tokens for registry access.
182func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
183	setNoStoreHeaders(w)
184
185	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
186		zerowrap.FieldLayer:   "adapter",
187		zerowrap.FieldAdapter: "http",
188		zerowrap.FieldHandler: "auth",
189		zerowrap.FieldMethod:  r.Method,
190		zerowrap.FieldPath:    r.URL.Path,
191	})
192	log := zerowrap.FromCtx(ctx)
193
194	if r.Method != http.MethodGet {
195		w.Header().Set("Content-Type", "application/json")
196		w.WriteHeader(http.StatusMethodNotAllowed)
197		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
198		return
199	}
200
201	// Check if auth is enabled
202	if h.authSvc == nil || !h.authSvc.IsEnabled() {
203		w.Header().Set("Content-Type", "application/json")
204		w.WriteHeader(http.StatusServiceUnavailable)
205		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
206		return
207	}
208
209	// Get credentials from Basic Auth
210	username, password, ok := r.BasicAuth()
211	if !ok {
212		w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
213		w.Header().Set("Content-Type", "application/json")
214		w.WriteHeader(http.StatusUnauthorized)
215		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
216		return
217	}
218
219	authenticated, parentClaims := h.authenticateTokenCredentials(ctx, r, username, password, log)
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	if parentClaims != nil {
234		requestedScopes = h.intersectRequestedScopes(requestedScopes, parentClaims.Scopes, log)
235		if len(requestedScopes) == 0 {
236			w.Header().Set("Content-Type", "application/json")
237			w.WriteHeader(http.StatusForbidden)
238			_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "forbidden: insufficient scope"})
239			return
240		}
241	}
242
243	// Generate a short-lived access token (5 minutes) - not stored
244	accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
245	if err != nil {
246		log.Error().Err(err).Msg("failed to generate access token")
247		w.Header().Set("Content-Type", "application/json")
248		w.WriteHeader(http.StatusInternalServerError)
249		_ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
250		return
251	}
252
253	response := dto.TokenResponse{
254		Token:     accessToken,
255		ExpiresIn: 300, // 5 minutes in seconds
256		IssuedAt:  time.Now().UTC().Format(time.RFC3339),
257	}
258
259	w.Header().Set("Content-Type", "application/json")
260	w.WriteHeader(http.StatusOK)
261
262	if err := json.NewEncoder(w).Encode(response); err != nil {
263		log.Error().Err(err).Msg("failed to encode token response")
264	}
265
266	log.Debug().
267		Str("username", username).
268		Int("expires_in", response.ExpiresIn).
269		Msg("access token issued")
270}
271
272func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
273	if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
274		log.Debug().Str("username", username).Msg("internal registry auth accepted")
275		return true, nil
276	}
277
278	// Always try JWT token validation first — even in password-auth mode the
279	// server issues JWTs on login, and the CLI sends them back via Basic Auth
280	// for registry token exchange.
281	claims, err := h.authSvc.ValidateToken(ctx, password)
282	if err == nil && claims.Subject == username {
283		return true, claims
284	}
285
286	// Fall back to password validation for direct username/password credentials.
287	if h.authSvc.GetAuthType() == domain.AuthTypePassword {
288		if h.authSvc.ValidatePassword(ctx, username, password) {
289			return true, nil
290		}
291	}
292
293	return false, nil
294}
295
296func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []string, log zerowrap.Logger) []string {
297	var effective []string
298
299	for _, reqScopeStr := range requestedScopes {
300		reqScope, err := domain.ParseScope(reqScopeStr)
301		if err != nil || reqScope.Type != domain.ScopeTypeRepository {
302			continue
303		}
304
305		allowedActions := make([]string, 0, len(reqScope.Actions))
306		for _, action := range reqScope.Actions {
307			if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
308				allowedActions = append(allowedActions, action)
309			}
310		}
311
312		if len(allowedActions) == 0 {
313			continue
314		}
315
316		effective = append(effective, (&domain.Scope{
317			Type:    reqScope.Type,
318			Name:    reqScope.Name,
319			Actions: allowedActions,
320		}).String())
321	}
322
323	log.Debug().
324		Strs("requested_scopes", requestedScopes).
325		Strs("granted_scopes", grantedScopes).
326		Strs("effective_scopes", effective).
327		Msg("calculated effective scopes from parent token")
328
329	return effective
330}
331
332func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
333	for _, grantedScopeStr := range grantedScopes {
334		scopeStr := strings.TrimSpace(grantedScopeStr)
335		switch scopeStr {
336		case domain.ScopeActionAll:
337			return true
338		case domain.ScopeActionPull, domain.ScopeActionPush:
339			if scopeStr == action {
340				return true
341			}
342		}
343
344		scope, err := domain.ParseScope(scopeStr)
345		if err != nil {
346			continue
347		}
348		if scope.Type != domain.ScopeTypeRepository {
349			continue
350		}
351		if scope.CanAccess(repoName, action) {
352			return true
353		}
354	}
355
356	return false
357}
358
359// parseRequestedScopes extracts and validates scope parameters from the request.
360// Per Docker Registry v2 auth spec, scope format is: repository:name:actions
361// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
362func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
363	scopeParams := r.URL.Query()["scope"]
364
365	if len(scopeParams) == 0 {
366		// Default scope: pull from any repository for backward compatibility
367		log.Debug().Msg("no scope requested, using default pull scope")
368		return []string{"repository:*:pull"}
369	}
370
371	var validScopes []string
372	for _, scopeStr := range scopeParams {
373		// Validate the scope format
374		scope, err := domain.ParseScope(scopeStr)
375		if err != nil {
376			log.Debug().
377				Err(err).
378				Str("scope", scopeStr).
379				Msg("invalid scope format, skipping")
380			continue
381		}
382
383		// Only allow repository scopes
384		if scope.Type != "repository" {
385			log.Debug().
386				Str("scope_type", scope.Type).
387				Str("scope", scopeStr).
388				Msg("unsupported scope type, skipping")
389			continue
390		}
391
392		validScopes = append(validScopes, scopeStr)
393	}
394
395	if len(validScopes) == 0 {
396		// All requested scopes were invalid, return default
397		log.Debug().Msg("all requested scopes invalid, using default pull scope")
398		return []string{"repository:*:pull"}
399	}
400
401	log.Debug().
402		Strs("scopes", validScopes).
403		Msg("parsed requested scopes")
404	return validScopes
405}
406
407// isInternalAuth checks if the credentials match internal registry auth.
408func (h *Handler) isInternalAuth(username, password string) bool {
409	if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
410		return false
411	}
412	usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(h.internalAuth.Username)) == 1
413	passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(h.internalAuth.Password)) == 1
414	return usernameMatch && passwordMatch
415}

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