v2.1.0 0fb867d

token.go RawHistoryFolder

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}

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