detached ee95a80

feat(auth): add unified /auth endpoints for password and token auth

commit
ee95a8017ac8c7ffad442dd2720767c517acffab
author
bnema · · 2026-01-18 03:16 UTC
parent
8c8f106
signature
unsigned
3 files · +559 -213
R internal/adapters/in/http/registry/token.go → internal/adapters/in/http/auth/handler.go +144 -21 renamed, 51% similar
@@ -1,4 +1,5 @@
1-package registry
1+// Package auth implements the HTTP adapter for authentication endpoints.
2+package auth
23
34 import (
45 "encoding/json"
@@ -19,34 +20,157 @@ type InternalAuth struct {
1920 Password string
2021 }
2122
22-// TokenHandler handles token server requests for Docker Registry authentication.
23-type TokenHandler struct {
23+// Handler handles authentication requests at /auth/*.
24+type Handler struct {
2425 authSvc in.AuthService
2526 internalAuth InternalAuth
2627 log zerowrap.Logger
2728 }
2829
29-// NewTokenHandler creates a new token handler.
30-func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler {
31- return &TokenHandler{
30+// NewHandler creates a new auth handler.
31+func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
32+ return &Handler{
3233 authSvc: authSvc,
3334 internalAuth: internalAuth,
3435 log: log,
3536 }
3637 }
3738
38-// ServeHTTP implements http.Handler for the token endpoint.
39-func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
40- h.handleToken(w, r)
39+// PasswordRequest represents the request body for POST /auth/password.
40+type PasswordRequest struct {
41+ Username string `json:"username"`
42+ Password string `json:"password"`
4143 }
4244
43-// handleToken handles GET /v2/token requests.
44-// This endpoint issues short-lived access tokens per the Docker Registry auth spec.
45-func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
45+// PasswordResponse represents the response from POST /auth/password.
46+type PasswordResponse struct {
47+ Token string `json:"token"`
48+ ExpiresIn int `json:"expires_in"`
49+ IssuedAt string `json:"issued_at"`
50+}
51+
52+// ServeHTTP routes requests to the appropriate handler.
53+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54+ path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
55+
56+ switch path {
57+ case "/password":
58+ h.handlePassword(w, r)
59+ case "/token":
60+ h.handleToken(w, r)
61+ default:
62+ w.Header().Set("Content-Type", "application/json")
63+ w.WriteHeader(http.StatusNotFound)
64+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
65+ }
66+}
67+
68+// handlePassword handles POST /auth/password requests.
69+// Validates username/password and returns a long-lived JWT (7 days).
70+// Only works when auth type is "password" - returns error for "token" auth type.
71+func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
72+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
73+ zerowrap.FieldLayer: "adapter",
74+ zerowrap.FieldAdapter: "http",
75+ zerowrap.FieldHandler: "auth",
76+ zerowrap.FieldMethod: r.Method,
77+ zerowrap.FieldPath: r.URL.Path,
78+ })
79+ log := zerowrap.FromCtx(ctx)
80+
81+ if r.Method != http.MethodPost {
82+ w.Header().Set("Content-Type", "application/json")
83+ w.WriteHeader(http.StatusMethodNotAllowed)
84+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
85+ return
86+ }
87+
88+ // Check if auth is enabled
89+ if h.authSvc == nil || !h.authSvc.IsEnabled() {
90+ w.Header().Set("Content-Type", "application/json")
91+ w.WriteHeader(http.StatusBadRequest)
92+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})
93+ return
94+ }
95+
96+ // Password endpoint only works with password auth type
97+ if h.authSvc.GetAuthType() != domain.AuthTypePassword {
98+ w.Header().Set("Content-Type", "application/json")
99+ w.WriteHeader(http.StatusBadRequest)
100+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
101+ Error: "password authentication not configured, use token-based auth",
102+ })
103+ return
104+ }
105+
106+ // Parse request body
107+ var req PasswordRequest
108+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
109+ w.Header().Set("Content-Type", "application/json")
110+ w.WriteHeader(http.StatusBadRequest)
111+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
112+ return
113+ }
114+
115+ if req.Username == "" || req.Password == "" {
116+ w.Header().Set("Content-Type", "application/json")
117+ w.WriteHeader(http.StatusBadRequest)
118+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
119+ return
120+ }
121+
122+ // Validate password
123+ if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
124+ log.Debug().
125+ Str("username", req.Username).
126+ Msg("password authentication failed")
127+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
128+ w.Header().Set("Content-Type", "application/json")
129+ w.WriteHeader(http.StatusUnauthorized)
130+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
131+ return
132+ }
133+
134+ // Generate 7-day token with full access (push, pull, admin)
135+ expiry := 7 * 24 * time.Hour
136+ scopes := []string{"push", "pull", "admin:*:*"}
137+
138+ token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
139+ if err != nil {
140+ log.Error().Err(err).Msg("failed to generate token")
141+ w.Header().Set("Content-Type", "application/json")
142+ w.WriteHeader(http.StatusInternalServerError)
143+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
144+ return
145+ }
146+
147+ response := PasswordResponse{
148+ Token: token,
149+ ExpiresIn: int(expiry.Seconds()),
150+ IssuedAt: time.Now().Format(time.RFC3339),
151+ }
152+
153+ w.Header().Set("Content-Type", "application/json")
154+ w.WriteHeader(http.StatusOK)
155+
156+ if err := json.NewEncoder(w).Encode(response); err != nil {
157+ log.Error().Err(err).Msg("failed to encode password response")
158+ }
159+
160+ log.Debug().
161+ Str("username", req.Username).
162+ Int("expires_in", response.ExpiresIn).
163+ Msg("long-lived token issued via password auth")
164+}
165+
166+// handleToken handles GET /auth/token requests.
167+// This is the Docker Registry v2 token server endpoint.
168+// Issues short-lived access tokens for registry access.
169+func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
46170 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
47171 zerowrap.FieldLayer: "adapter",
48172 zerowrap.FieldAdapter: "http",
49- zerowrap.FieldHandler: "token",
173+ zerowrap.FieldHandler: "auth",
50174 zerowrap.FieldMethod: r.Method,
51175 zerowrap.FieldPath: r.URL.Path,
52176 })
@@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
55179 if r.Method != http.MethodGet {
56180 w.Header().Set("Content-Type", "application/json")
57181 w.WriteHeader(http.StatusMethodNotAllowed)
58- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"})
182+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
59183 return
60184 }
61185
@@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
100224 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
101225 w.Header().Set("Content-Type", "application/json")
102226 w.WriteHeader(http.StatusUnauthorized)
103- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})
227+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
104228 return
105229 }
106230
107231 // Parse requested scopes from query parameter
108- // Format: scope=repository:name:action1,action2 (can appear multiple times)
109232 requestedScopes := h.parseRequestedScopes(r, log)
110233
111234 // Generate a short-lived access token (5 minutes) - not stored
@@ -114,7 +237,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
114237 log.Error().Err(err).Msg("failed to generate access token")
115238 w.Header().Set("Content-Type", "application/json")
116239 w.WriteHeader(http.StatusInternalServerError)
117- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})
240+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
118241 return
119242 }
120243
@@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
138261 }
139262
140263 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
141-func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
264+func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
142265 // For anonymous access, we issue a very short-lived token with limited scope
143266 response := dto.TokenResponse{
144267 Token: "", // Empty token indicates limited access
@@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool {
168291
169292 // parseRequestedScopes extracts and validates scope parameters from the request.
170293 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
171-// Example: GET /v2/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
172-func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
294+// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
295+func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
173296 scopeParams := r.URL.Query()["scope"]
174297
175298 if len(scopeParams) == 0 {
@@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger
215338 }
216339
217340 // isInternalAuth checks if the credentials match internal registry auth.
218-func (h *TokenHandler) isInternalAuth(username, password string) bool {
341+func (h *Handler) isInternalAuth(username, password string) bool {
219342 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
220343 return false
221344 }
@@ -1,4 +1,5 @@
1-package registry1+// Package auth implements the HTTP adapter for authentication endpoints.
2+package auth
2 3
3 import (4 import (
4 "encoding/json"5 "encoding/json"
@@ -19,34 +20,157 @@ type InternalAuth struct {
19 Password string20 Password string
20 }21 }
21 22
22-// TokenHandler handles token server requests for Docker Registry authentication.23+// Handler handles authentication requests at /auth/*.
23-type TokenHandler struct {24+type Handler struct {
24 authSvc in.AuthService25 authSvc in.AuthService
25 internalAuth InternalAuth26 internalAuth InternalAuth
26 log zerowrap.Logger27 log zerowrap.Logger
27 }28 }
28 29
29-// NewTokenHandler creates a new token handler.30+// NewHandler creates a new auth handler.
30-func NewTokenHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *TokenHandler {31+func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.Logger) *Handler {
31- return &TokenHandler{32+ return &Handler{
32 authSvc: authSvc,33 authSvc: authSvc,
33 internalAuth: internalAuth,34 internalAuth: internalAuth,
34 log: log,35 log: log,
35 }36 }
36 }37 }
37 38
38-// ServeHTTP implements http.Handler for the token endpoint.39+// PasswordRequest represents the request body for POST /auth/password.
39-func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {40+type PasswordRequest struct {
40- h.handleToken(w, r)41+ Username string `json:"username"`
42+ Password string `json:"password"`
41 }43 }
42 44
43-// handleToken handles GET /v2/token requests.45+// PasswordResponse represents the response from POST /auth/password.
44-// This endpoint issues short-lived access tokens per the Docker Registry auth spec.46+type PasswordResponse struct {
45-func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {47+ Token string `json:"token"`
48+ ExpiresIn int `json:"expires_in"`
49+ IssuedAt string `json:"issued_at"`
50+}
51+
52+// ServeHTTP routes requests to the appropriate handler.
53+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54+ path := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/auth"), "/")
55+
56+ switch path {
57+ case "/password":
58+ h.handlePassword(w, r)
59+ case "/token":
60+ h.handleToken(w, r)
61+ default:
62+ w.Header().Set("Content-Type", "application/json")
63+ w.WriteHeader(http.StatusNotFound)
64+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "not found"})
65+ }
66+}
67+
68+// handlePassword handles POST /auth/password requests.
69+// Validates username/password and returns a long-lived JWT (7 days).
70+// Only works when auth type is "password" - returns error for "token" auth type.
71+func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
72+ ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
73+ zerowrap.FieldLayer: "adapter",
74+ zerowrap.FieldAdapter: "http",
75+ zerowrap.FieldHandler: "auth",
76+ zerowrap.FieldMethod: r.Method,
77+ zerowrap.FieldPath: r.URL.Path,
78+ })
79+ log := zerowrap.FromCtx(ctx)
80+
81+ if r.Method != http.MethodPost {
82+ w.Header().Set("Content-Type", "application/json")
83+ w.WriteHeader(http.StatusMethodNotAllowed)
84+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
85+ return
86+ }
87+
88+ // Check if auth is enabled
89+ if h.authSvc == nil || !h.authSvc.IsEnabled() {
90+ w.Header().Set("Content-Type", "application/json")
91+ w.WriteHeader(http.StatusBadRequest)
92+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is disabled"})
93+ return
94+ }
95+
96+ // Password endpoint only works with password auth type
97+ if h.authSvc.GetAuthType() != domain.AuthTypePassword {
98+ w.Header().Set("Content-Type", "application/json")
99+ w.WriteHeader(http.StatusBadRequest)
100+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
101+ Error: "password authentication not configured, use token-based auth",
102+ })
103+ return
104+ }
105+
106+ // Parse request body
107+ var req PasswordRequest
108+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
109+ w.Header().Set("Content-Type", "application/json")
110+ w.WriteHeader(http.StatusBadRequest)
111+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
112+ return
113+ }
114+
115+ if req.Username == "" || req.Password == "" {
116+ w.Header().Set("Content-Type", "application/json")
117+ w.WriteHeader(http.StatusBadRequest)
118+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
119+ return
120+ }
121+
122+ // Validate password
123+ if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
124+ log.Debug().
125+ Str("username", req.Username).
126+ Msg("password authentication failed")
127+ w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
128+ w.Header().Set("Content-Type", "application/json")
129+ w.WriteHeader(http.StatusUnauthorized)
130+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
131+ return
132+ }
133+
134+ // Generate 7-day token with full access (push, pull, admin)
135+ expiry := 7 * 24 * time.Hour
136+ scopes := []string{"push", "pull", "admin:*:*"}
137+
138+ token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
139+ if err != nil {
140+ log.Error().Err(err).Msg("failed to generate token")
141+ w.Header().Set("Content-Type", "application/json")
142+ w.WriteHeader(http.StatusInternalServerError)
143+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
144+ return
145+ }
146+
147+ response := PasswordResponse{
148+ Token: token,
149+ ExpiresIn: int(expiry.Seconds()),
150+ IssuedAt: time.Now().Format(time.RFC3339),
151+ }
152+
153+ w.Header().Set("Content-Type", "application/json")
154+ w.WriteHeader(http.StatusOK)
155+
156+ if err := json.NewEncoder(w).Encode(response); err != nil {
157+ log.Error().Err(err).Msg("failed to encode password response")
158+ }
159+
160+ log.Debug().
161+ Str("username", req.Username).
162+ Int("expires_in", response.ExpiresIn).
163+ Msg("long-lived token issued via password auth")
164+}
165+
166+// handleToken handles GET /auth/token requests.
167+// This is the Docker Registry v2 token server endpoint.
168+// Issues short-lived access tokens for registry access.
169+func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
46 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{170 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
47 zerowrap.FieldLayer: "adapter",171 zerowrap.FieldLayer: "adapter",
48 zerowrap.FieldAdapter: "http",172 zerowrap.FieldAdapter: "http",
49- zerowrap.FieldHandler: "token",173+ zerowrap.FieldHandler: "auth",
50 zerowrap.FieldMethod: r.Method,174 zerowrap.FieldMethod: r.Method,
51 zerowrap.FieldPath: r.URL.Path,175 zerowrap.FieldPath: r.URL.Path,
52 })176 })
@@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
55 if r.Method != http.MethodGet {179 if r.Method != http.MethodGet {
56 w.Header().Set("Content-Type", "application/json")180 w.Header().Set("Content-Type", "application/json")
57 w.WriteHeader(http.StatusMethodNotAllowed)181 w.WriteHeader(http.StatusMethodNotAllowed)
58- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Method not allowed"})182+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
59 return183 return
60 }184 }
61 185
@@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
100 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)224 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
101 w.Header().Set("Content-Type", "application/json")225 w.Header().Set("Content-Type", "application/json")
102 w.WriteHeader(http.StatusUnauthorized)226 w.WriteHeader(http.StatusUnauthorized)
103- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"})227+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"})
104 return228 return
105 }229 }
106 230
107 // Parse requested scopes from query parameter231 // Parse requested scopes from query parameter
108- // Format: scope=repository:name:action1,action2 (can appear multiple times)
109 requestedScopes := h.parseRequestedScopes(r, log)232 requestedScopes := h.parseRequestedScopes(r, log)
110 233
111 // Generate a short-lived access token (5 minutes) - not stored234 // Generate a short-lived access token (5 minutes) - not stored
@@ -114,7 +237,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
114 log.Error().Err(err).Msg("failed to generate access token")237 log.Error().Err(err).Msg("failed to generate access token")
115 w.Header().Set("Content-Type", "application/json")238 w.Header().Set("Content-Type", "application/json")
116 w.WriteHeader(http.StatusInternalServerError)239 w.WriteHeader(http.StatusInternalServerError)
117- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Internal Server Error"})240+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "internal server error"})
118 return241 return
119 }242 }
120 243
@@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) {
138 }261 }
139 262
140 // sendAnonymousToken sends a token for anonymous/unauthenticated access.263 // sendAnonymousToken sends a token for anonymous/unauthenticated access.
141-func (h *TokenHandler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {264+func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) {
142 // For anonymous access, we issue a very short-lived token with limited scope265 // For anonymous access, we issue a very short-lived token with limited scope
143 response := dto.TokenResponse{266 response := dto.TokenResponse{
144 Token: "", // Empty token indicates limited access267 Token: "", // Empty token indicates limited access
@@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool {
168 291
169 // parseRequestedScopes extracts and validates scope parameters from the request.292 // parseRequestedScopes extracts and validates scope parameters from the request.
170 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions293 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
171-// Example: GET /v2/token?scope=repository:myrepo:push,pull&scope=repository:other:pull294+// Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
172-func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {295+func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []string {
173 scopeParams := r.URL.Query()["scope"]296 scopeParams := r.URL.Query()["scope"]
174 297
175 if len(scopeParams) == 0 {298 if len(scopeParams) == 0 {
@@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger
215 }338 }
216 339
217 // isInternalAuth checks if the credentials match internal registry auth.340 // isInternalAuth checks if the credentials match internal registry auth.
218-func (h *TokenHandler) isInternalAuth(username, password string) bool {341+func (h *Handler) isInternalAuth(username, password string) bool {
219 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {342 if h.internalAuth.Username == "" || h.internalAuth.Password == "" {
220 return false343 return false
221 }344 }
A internal/adapters/in/http/auth/handler_test.go +415 -0
@@ -0,0 +1,415 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+
15+ "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestHandler_Password_Success(t *testing.T) {
24+ authSvc := mocks.NewMockAuthService(t)
25+
26+ authSvc.EXPECT().IsEnabled().Return(true)
27+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
28+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
29+ authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour).
30+ Return("jwt-token-here", nil)
31+
32+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
33+
34+ body := `{"username":"admin","password":"secret"}`
35+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
36+ req.Header.Set("Content-Type", "application/json")
37+ rec := httptest.NewRecorder()
38+
39+ handler.ServeHTTP(rec, req)
40+
41+ assert.Equal(t, http.StatusOK, rec.Code)
42+
43+ var resp PasswordResponse
44+ err := json.NewDecoder(rec.Body).Decode(&resp)
45+ assert.NoError(t, err)
46+ assert.Equal(t, "jwt-token-here", resp.Token)
47+ assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds
48+ assert.NotEmpty(t, resp.IssuedAt)
49+}
50+
51+func TestHandler_Password_InvalidCredentials(t *testing.T) {
52+ authSvc := mocks.NewMockAuthService(t)
53+
54+ authSvc.EXPECT().IsEnabled().Return(true)
55+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
56+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
57+
58+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
59+
60+ body := `{"username":"admin","password":"wrongpassword"}`
61+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
62+ req.Header.Set("Content-Type", "application/json")
63+ rec := httptest.NewRecorder()
64+
65+ handler.ServeHTTP(rec, req)
66+
67+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
68+ assert.Contains(t, rec.Body.String(), "invalid credentials")
69+}
70+
71+func TestHandler_Password_AuthTypeToken(t *testing.T) {
72+ authSvc := mocks.NewMockAuthService(t)
73+
74+ authSvc.EXPECT().IsEnabled().Return(true)
75+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
76+
77+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
78+
79+ body := `{"username":"admin","password":"secret"}`
80+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
81+ req.Header.Set("Content-Type", "application/json")
82+ rec := httptest.NewRecorder()
83+
84+ handler.ServeHTTP(rec, req)
85+
86+ assert.Equal(t, http.StatusBadRequest, rec.Code)
87+ assert.Contains(t, rec.Body.String(), "password authentication not configured")
88+}
89+
90+func TestHandler_Password_AuthDisabled(t *testing.T) {
91+ authSvc := mocks.NewMockAuthService(t)
92+
93+ authSvc.EXPECT().IsEnabled().Return(false)
94+
95+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
96+
97+ body := `{"username":"admin","password":"secret"}`
98+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
99+ req.Header.Set("Content-Type", "application/json")
100+ rec := httptest.NewRecorder()
101+
102+ handler.ServeHTTP(rec, req)
103+
104+ assert.Equal(t, http.StatusBadRequest, rec.Code)
105+ assert.Contains(t, rec.Body.String(), "authentication is disabled")
106+}
107+
108+func TestHandler_Password_MissingFields(t *testing.T) {
109+ authSvc := mocks.NewMockAuthService(t)
110+
111+ authSvc.EXPECT().IsEnabled().Return(true)
112+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
113+
114+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
115+
116+ tests := []struct {
117+ name string
118+ body string
119+ }{
120+ {"missing username", `{"password":"secret"}`},
121+ {"missing password", `{"username":"admin"}`},
122+ {"empty username", `{"username":"","password":"secret"}`},
123+ {"empty password", `{"username":"admin","password":""}`},
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
129+ req.Header.Set("Content-Type", "application/json")
130+ rec := httptest.NewRecorder()
131+
132+ handler.ServeHTTP(rec, req)
133+
134+ assert.Equal(t, http.StatusBadRequest, rec.Code)
135+ })
136+ }
137+}
138+
139+func TestHandler_Password_MethodNotAllowed(t *testing.T) {
140+ handler := NewHandler(nil, InternalAuth{}, testLogger())
141+
142+ methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
143+
144+ for _, method := range methods {
145+ t.Run(method, func(t *testing.T) {
146+ req := httptest.NewRequest(method, "/auth/password", nil)
147+ rec := httptest.NewRecorder()
148+
149+ handler.ServeHTTP(rec, req)
150+
151+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
152+ })
153+ }
154+}
155+
156+func TestHandler_Token_Success(t *testing.T) {
157+ authSvc := mocks.NewMockAuthService(t)
158+
159+ authSvc.EXPECT().IsEnabled().Return(true)
160+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
161+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
162+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
163+ Return("access-token", nil)
164+
165+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
166+
167+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
168+ req.SetBasicAuth("admin", "secret")
169+ rec := httptest.NewRecorder()
170+
171+ handler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, http.StatusOK, rec.Code)
174+
175+ var resp map[string]any
176+ err := json.NewDecoder(rec.Body).Decode(&resp)
177+ assert.NoError(t, err)
178+ assert.Equal(t, "access-token", resp["token"])
179+ assert.Equal(t, float64(300), resp["expires_in"])
180+}
181+
182+func TestHandler_Token_Anonymous(t *testing.T) {
183+ authSvc := mocks.NewMockAuthService(t)
184+
185+ authSvc.EXPECT().IsEnabled().Return(true)
186+
187+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
188+
189+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
190+ rec := httptest.NewRecorder()
191+
192+ handler.ServeHTTP(rec, req)
193+
194+ assert.Equal(t, http.StatusOK, rec.Code)
195+
196+ var resp map[string]any
197+ err := json.NewDecoder(rec.Body).Decode(&resp)
198+ assert.NoError(t, err)
199+ assert.Equal(t, "", resp["token"])
200+ assert.Equal(t, float64(60), resp["expires_in"])
201+}
202+
203+func TestHandler_Token_AuthDisabled(t *testing.T) {
204+ authSvc := mocks.NewMockAuthService(t)
205+
206+ authSvc.EXPECT().IsEnabled().Return(false)
207+
208+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
209+
210+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
211+ rec := httptest.NewRecorder()
212+
213+ handler.ServeHTTP(rec, req)
214+
215+ // Should return anonymous token when auth is disabled
216+ assert.Equal(t, http.StatusOK, rec.Code)
217+
218+ var resp map[string]any
219+ err := json.NewDecoder(rec.Body).Decode(&resp)
220+ assert.NoError(t, err)
221+ assert.Equal(t, "", resp["token"])
222+}
223+
224+func TestHandler_Token_InvalidCredentials(t *testing.T) {
225+ authSvc := mocks.NewMockAuthService(t)
226+
227+ authSvc.EXPECT().IsEnabled().Return(true)
228+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
229+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
230+
231+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
232+
233+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
234+ req.SetBasicAuth("admin", "wrongpassword")
235+ rec := httptest.NewRecorder()
236+
237+ handler.ServeHTTP(rec, req)
238+
239+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
240+}
241+
242+func TestHandler_Token_InternalAuth(t *testing.T) {
243+ authSvc := mocks.NewMockAuthService(t)
244+
245+ authSvc.EXPECT().IsEnabled().Return(true)
246+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
247+ Return("internal-access-token", nil)
248+
249+ internalAuth := InternalAuth{
250+ Username: "gordon-internal",
251+ Password: "internal-secret",
252+ }
253+ handler := NewHandler(authSvc, internalAuth, testLogger())
254+
255+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
256+ req.RemoteAddr = "127.0.0.1:12345"
257+ req.SetBasicAuth("gordon-internal", "internal-secret")
258+ rec := httptest.NewRecorder()
259+
260+ handler.ServeHTTP(rec, req)
261+
262+ assert.Equal(t, http.StatusOK, rec.Code)
263+
264+ var resp map[string]any
265+ err := json.NewDecoder(rec.Body).Decode(&resp)
266+ assert.NoError(t, err)
267+ assert.Equal(t, "internal-access-token", resp["token"])
268+}
269+
270+func TestHandler_Token_TokenAuth(t *testing.T) {
271+ authSvc := mocks.NewMockAuthService(t)
272+
273+ authSvc.EXPECT().IsEnabled().Return(true)
274+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276+ Subject: "myuser",
277+ }, nil)
278+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279+ Return("short-lived-token", nil)
280+
281+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
282+
283+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
284+ req.SetBasicAuth("myuser", "existing-jwt-token")
285+ rec := httptest.NewRecorder()
286+
287+ handler.ServeHTTP(rec, req)
288+
289+ assert.Equal(t, http.StatusOK, rec.Code)
290+
291+ var resp map[string]any
292+ err := json.NewDecoder(rec.Body).Decode(&resp)
293+ assert.NoError(t, err)
294+ assert.Equal(t, "short-lived-token", resp["token"])
295+}
296+
297+func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298+ handler := NewHandler(nil, InternalAuth{}, testLogger())
299+
300+ methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
301+
302+ for _, method := range methods {
303+ t.Run(method, func(t *testing.T) {
304+ req := httptest.NewRequest(method, "/auth/token", nil)
305+ rec := httptest.NewRecorder()
306+
307+ handler.ServeHTTP(rec, req)
308+
309+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
310+ })
311+ }
312+}
313+
314+func TestHandler_NotFound(t *testing.T) {
315+ handler := NewHandler(nil, InternalAuth{}, testLogger())
316+
317+ paths := []string{"/auth/unknown", "/auth/", "/auth"}
318+
319+ for _, path := range paths {
320+ t.Run(path, func(t *testing.T) {
321+ req := httptest.NewRequest(http.MethodGet, path, nil)
322+ rec := httptest.NewRecorder()
323+
324+ handler.ServeHTTP(rec, req)
325+
326+ assert.Equal(t, http.StatusNotFound, rec.Code)
327+ })
328+ }
329+}
330+
331+func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
332+ log := testLogger()
333+ h := &Handler{log: log}
334+
335+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
336+
337+ scopes := h.parseRequestedScopes(req, log)
338+
339+ assert.Len(t, scopes, 2)
340+ assert.Contains(t, scopes, "repository:repo1:pull")
341+ assert.Contains(t, scopes, "repository:repo2:push")
342+}
343+
344+func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
345+ log := testLogger()
346+ h := &Handler{log: log}
347+
348+ tests := []struct {
349+ name string
350+ query string
351+ wantScopes []string
352+ }{
353+ {
354+ name: "invalid format is skipped",
355+ query: "scope=invalid-no-colons&scope=repository:valid:pull",
356+ wantScopes: []string{"repository:valid:pull"},
357+ },
358+ {
359+ name: "missing actions is skipped",
360+ query: "scope=repository:myrepo&scope=repository:valid:pull",
361+ wantScopes: []string{"repository:valid:pull"},
362+ },
363+ {
364+ name: "all invalid returns default",
365+ query: "scope=invalid&scope=also-invalid",
366+ wantScopes: []string{"repository:*:pull"},
367+ },
368+ }
369+
370+ for _, tt := range tests {
371+ t.Run(tt.name, func(t *testing.T) {
372+ req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
373+ scopes := h.parseRequestedScopes(req, log)
374+
375+ assert.Equal(t, tt.wantScopes, scopes)
376+ })
377+ }
378+}
379+
380+func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
381+ log := testLogger()
382+ h := &Handler{log: log}
383+
384+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
385+ scopes := h.parseRequestedScopes(req, log)
386+
387+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
388+}
389+
390+func TestIsLocalhostRequest(t *testing.T) {
391+ tests := []struct {
392+ name string
393+ remoteAddr string
394+ want bool
395+ }{
396+ {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
397+ {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
398+ {"::1 is localhost", "[::1]:12345", true},
399+ {"::1 without brackets", "::1:12345", true},
400+ {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
401+ {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
402+ {"public IP is not localhost", "8.8.8.8:12345", false},
403+ }
404+
405+ for _, tt := range tests {
406+ t.Run(tt.name, func(t *testing.T) {
407+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
408+ req.RemoteAddr = tt.remoteAddr
409+
410+ result := isLocalhostRequest(req)
411+
412+ assert.Equal(t, tt.want, result)
413+ })
414+ }
415+}
@@ -0,0 +1,415 @@
1+package auth
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+
15+ "gordon/internal/boundaries/in/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestHandler_Password_Success(t *testing.T) {
24+ authSvc := mocks.NewMockAuthService(t)
25+
26+ authSvc.EXPECT().IsEnabled().Return(true)
27+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
28+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
29+ authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour).
30+ Return("jwt-token-here", nil)
31+
32+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
33+
34+ body := `{"username":"admin","password":"secret"}`
35+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
36+ req.Header.Set("Content-Type", "application/json")
37+ rec := httptest.NewRecorder()
38+
39+ handler.ServeHTTP(rec, req)
40+
41+ assert.Equal(t, http.StatusOK, rec.Code)
42+
43+ var resp PasswordResponse
44+ err := json.NewDecoder(rec.Body).Decode(&resp)
45+ assert.NoError(t, err)
46+ assert.Equal(t, "jwt-token-here", resp.Token)
47+ assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds
48+ assert.NotEmpty(t, resp.IssuedAt)
49+}
50+
51+func TestHandler_Password_InvalidCredentials(t *testing.T) {
52+ authSvc := mocks.NewMockAuthService(t)
53+
54+ authSvc.EXPECT().IsEnabled().Return(true)
55+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
56+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
57+
58+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
59+
60+ body := `{"username":"admin","password":"wrongpassword"}`
61+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
62+ req.Header.Set("Content-Type", "application/json")
63+ rec := httptest.NewRecorder()
64+
65+ handler.ServeHTTP(rec, req)
66+
67+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
68+ assert.Contains(t, rec.Body.String(), "invalid credentials")
69+}
70+
71+func TestHandler_Password_AuthTypeToken(t *testing.T) {
72+ authSvc := mocks.NewMockAuthService(t)
73+
74+ authSvc.EXPECT().IsEnabled().Return(true)
75+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
76+
77+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
78+
79+ body := `{"username":"admin","password":"secret"}`
80+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
81+ req.Header.Set("Content-Type", "application/json")
82+ rec := httptest.NewRecorder()
83+
84+ handler.ServeHTTP(rec, req)
85+
86+ assert.Equal(t, http.StatusBadRequest, rec.Code)
87+ assert.Contains(t, rec.Body.String(), "password authentication not configured")
88+}
89+
90+func TestHandler_Password_AuthDisabled(t *testing.T) {
91+ authSvc := mocks.NewMockAuthService(t)
92+
93+ authSvc.EXPECT().IsEnabled().Return(false)
94+
95+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
96+
97+ body := `{"username":"admin","password":"secret"}`
98+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
99+ req.Header.Set("Content-Type", "application/json")
100+ rec := httptest.NewRecorder()
101+
102+ handler.ServeHTTP(rec, req)
103+
104+ assert.Equal(t, http.StatusBadRequest, rec.Code)
105+ assert.Contains(t, rec.Body.String(), "authentication is disabled")
106+}
107+
108+func TestHandler_Password_MissingFields(t *testing.T) {
109+ authSvc := mocks.NewMockAuthService(t)
110+
111+ authSvc.EXPECT().IsEnabled().Return(true)
112+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
113+
114+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
115+
116+ tests := []struct {
117+ name string
118+ body string
119+ }{
120+ {"missing username", `{"password":"secret"}`},
121+ {"missing password", `{"username":"admin"}`},
122+ {"empty username", `{"username":"","password":"secret"}`},
123+ {"empty password", `{"username":"admin","password":""}`},
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
129+ req.Header.Set("Content-Type", "application/json")
130+ rec := httptest.NewRecorder()
131+
132+ handler.ServeHTTP(rec, req)
133+
134+ assert.Equal(t, http.StatusBadRequest, rec.Code)
135+ })
136+ }
137+}
138+
139+func TestHandler_Password_MethodNotAllowed(t *testing.T) {
140+ handler := NewHandler(nil, InternalAuth{}, testLogger())
141+
142+ methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
143+
144+ for _, method := range methods {
145+ t.Run(method, func(t *testing.T) {
146+ req := httptest.NewRequest(method, "/auth/password", nil)
147+ rec := httptest.NewRecorder()
148+
149+ handler.ServeHTTP(rec, req)
150+
151+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
152+ })
153+ }
154+}
155+
156+func TestHandler_Token_Success(t *testing.T) {
157+ authSvc := mocks.NewMockAuthService(t)
158+
159+ authSvc.EXPECT().IsEnabled().Return(true)
160+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
161+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
162+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
163+ Return("access-token", nil)
164+
165+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
166+
167+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
168+ req.SetBasicAuth("admin", "secret")
169+ rec := httptest.NewRecorder()
170+
171+ handler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, http.StatusOK, rec.Code)
174+
175+ var resp map[string]any
176+ err := json.NewDecoder(rec.Body).Decode(&resp)
177+ assert.NoError(t, err)
178+ assert.Equal(t, "access-token", resp["token"])
179+ assert.Equal(t, float64(300), resp["expires_in"])
180+}
181+
182+func TestHandler_Token_Anonymous(t *testing.T) {
183+ authSvc := mocks.NewMockAuthService(t)
184+
185+ authSvc.EXPECT().IsEnabled().Return(true)
186+
187+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
188+
189+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
190+ rec := httptest.NewRecorder()
191+
192+ handler.ServeHTTP(rec, req)
193+
194+ assert.Equal(t, http.StatusOK, rec.Code)
195+
196+ var resp map[string]any
197+ err := json.NewDecoder(rec.Body).Decode(&resp)
198+ assert.NoError(t, err)
199+ assert.Equal(t, "", resp["token"])
200+ assert.Equal(t, float64(60), resp["expires_in"])
201+}
202+
203+func TestHandler_Token_AuthDisabled(t *testing.T) {
204+ authSvc := mocks.NewMockAuthService(t)
205+
206+ authSvc.EXPECT().IsEnabled().Return(false)
207+
208+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
209+
210+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
211+ rec := httptest.NewRecorder()
212+
213+ handler.ServeHTTP(rec, req)
214+
215+ // Should return anonymous token when auth is disabled
216+ assert.Equal(t, http.StatusOK, rec.Code)
217+
218+ var resp map[string]any
219+ err := json.NewDecoder(rec.Body).Decode(&resp)
220+ assert.NoError(t, err)
221+ assert.Equal(t, "", resp["token"])
222+}
223+
224+func TestHandler_Token_InvalidCredentials(t *testing.T) {
225+ authSvc := mocks.NewMockAuthService(t)
226+
227+ authSvc.EXPECT().IsEnabled().Return(true)
228+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
229+ authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
230+
231+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
232+
233+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
234+ req.SetBasicAuth("admin", "wrongpassword")
235+ rec := httptest.NewRecorder()
236+
237+ handler.ServeHTTP(rec, req)
238+
239+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
240+}
241+
242+func TestHandler_Token_InternalAuth(t *testing.T) {
243+ authSvc := mocks.NewMockAuthService(t)
244+
245+ authSvc.EXPECT().IsEnabled().Return(true)
246+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
247+ Return("internal-access-token", nil)
248+
249+ internalAuth := InternalAuth{
250+ Username: "gordon-internal",
251+ Password: "internal-secret",
252+ }
253+ handler := NewHandler(authSvc, internalAuth, testLogger())
254+
255+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
256+ req.RemoteAddr = "127.0.0.1:12345"
257+ req.SetBasicAuth("gordon-internal", "internal-secret")
258+ rec := httptest.NewRecorder()
259+
260+ handler.ServeHTTP(rec, req)
261+
262+ assert.Equal(t, http.StatusOK, rec.Code)
263+
264+ var resp map[string]any
265+ err := json.NewDecoder(rec.Body).Decode(&resp)
266+ assert.NoError(t, err)
267+ assert.Equal(t, "internal-access-token", resp["token"])
268+}
269+
270+func TestHandler_Token_TokenAuth(t *testing.T) {
271+ authSvc := mocks.NewMockAuthService(t)
272+
273+ authSvc.EXPECT().IsEnabled().Return(true)
274+ authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276+ Subject: "myuser",
277+ }, nil)
278+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279+ Return("short-lived-token", nil)
280+
281+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
282+
283+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
284+ req.SetBasicAuth("myuser", "existing-jwt-token")
285+ rec := httptest.NewRecorder()
286+
287+ handler.ServeHTTP(rec, req)
288+
289+ assert.Equal(t, http.StatusOK, rec.Code)
290+
291+ var resp map[string]any
292+ err := json.NewDecoder(rec.Body).Decode(&resp)
293+ assert.NoError(t, err)
294+ assert.Equal(t, "short-lived-token", resp["token"])
295+}
296+
297+func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298+ handler := NewHandler(nil, InternalAuth{}, testLogger())
299+
300+ methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
301+
302+ for _, method := range methods {
303+ t.Run(method, func(t *testing.T) {
304+ req := httptest.NewRequest(method, "/auth/token", nil)
305+ rec := httptest.NewRecorder()
306+
307+ handler.ServeHTTP(rec, req)
308+
309+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
310+ })
311+ }
312+}
313+
314+func TestHandler_NotFound(t *testing.T) {
315+ handler := NewHandler(nil, InternalAuth{}, testLogger())
316+
317+ paths := []string{"/auth/unknown", "/auth/", "/auth"}
318+
319+ for _, path := range paths {
320+ t.Run(path, func(t *testing.T) {
321+ req := httptest.NewRequest(http.MethodGet, path, nil)
322+ rec := httptest.NewRecorder()
323+
324+ handler.ServeHTTP(rec, req)
325+
326+ assert.Equal(t, http.StatusNotFound, rec.Code)
327+ })
328+ }
329+}
330+
331+func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
332+ log := testLogger()
333+ h := &Handler{log: log}
334+
335+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
336+
337+ scopes := h.parseRequestedScopes(req, log)
338+
339+ assert.Len(t, scopes, 2)
340+ assert.Contains(t, scopes, "repository:repo1:pull")
341+ assert.Contains(t, scopes, "repository:repo2:push")
342+}
343+
344+func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
345+ log := testLogger()
346+ h := &Handler{log: log}
347+
348+ tests := []struct {
349+ name string
350+ query string
351+ wantScopes []string
352+ }{
353+ {
354+ name: "invalid format is skipped",
355+ query: "scope=invalid-no-colons&scope=repository:valid:pull",
356+ wantScopes: []string{"repository:valid:pull"},
357+ },
358+ {
359+ name: "missing actions is skipped",
360+ query: "scope=repository:myrepo&scope=repository:valid:pull",
361+ wantScopes: []string{"repository:valid:pull"},
362+ },
363+ {
364+ name: "all invalid returns default",
365+ query: "scope=invalid&scope=also-invalid",
366+ wantScopes: []string{"repository:*:pull"},
367+ },
368+ }
369+
370+ for _, tt := range tests {
371+ t.Run(tt.name, func(t *testing.T) {
372+ req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
373+ scopes := h.parseRequestedScopes(req, log)
374+
375+ assert.Equal(t, tt.wantScopes, scopes)
376+ })
377+ }
378+}
379+
380+func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
381+ log := testLogger()
382+ h := &Handler{log: log}
383+
384+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
385+ scopes := h.parseRequestedScopes(req, log)
386+
387+ assert.Equal(t, []string{"repository:*:pull"}, scopes)
388+}
389+
390+func TestIsLocalhostRequest(t *testing.T) {
391+ tests := []struct {
392+ name string
393+ remoteAddr string
394+ want bool
395+ }{
396+ {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
397+ {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
398+ {"::1 is localhost", "[::1]:12345", true},
399+ {"::1 without brackets", "::1:12345", true},
400+ {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
401+ {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
402+ {"public IP is not localhost", "8.8.8.8:12345", false},
403+ }
404+
405+ for _, tt := range tests {
406+ t.Run(tt.name, func(t *testing.T) {
407+ req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
408+ req.RemoteAddr = tt.remoteAddr
409+
410+ result := isLocalhostRequest(req)
411+
412+ assert.Equal(t, tt.want, result)
413+ })
414+ }
415+}
D internal/adapters/in/http/registry/token_test.go +0 -192
@@ -1,192 +0,0 @@
1-package registry
2-
3-import (
4- "net/http"
5- "net/http/httptest"
6- "testing"
7-
8- "github.com/bnema/zerowrap"
9- "github.com/stretchr/testify/assert"
10-)
11-
12-func tokenTestLogger() zerowrap.Logger {
13- return zerowrap.Default()
14-}
15-
16-func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
17- log := tokenTestLogger()
18- h := &TokenHandler{log: log}
19-
20- req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
21-
22- scopes := h.parseRequestedScopes(req, log)
23-
24- assert.Len(t, scopes, 2)
25- assert.Contains(t, scopes, "repository:repo1:pull")
26- assert.Contains(t, scopes, "repository:repo2:push")
27-}
28-
29-func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
30- log := tokenTestLogger()
31- h := &TokenHandler{log: log}
32-
33- tests := []struct {
34- name string
35- query string
36- wantScopes []string
37- wantDefault bool
38- }{
39- {
40- name: "invalid format is skipped",
41- query: "scope=invalid-no-colons&scope=repository:valid:pull",
42- wantScopes: []string{"repository:valid:pull"},
43- wantDefault: false,
44- },
45- {
46- name: "missing actions is skipped",
47- query: "scope=repository:myrepo&scope=repository:valid:pull",
48- wantScopes: []string{"repository:valid:pull"},
49- wantDefault: false,
50- },
51- {
52- name: "all invalid returns default",
53- query: "scope=invalid&scope=also-invalid",
54- wantScopes: []string{"repository:*:pull"},
55- wantDefault: true,
56- },
57- }
58-
59- for _, tt := range tests {
60- t.Run(tt.name, func(t *testing.T) {
61- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
62- scopes := h.parseRequestedScopes(req, log)
63-
64- assert.Equal(t, tt.wantScopes, scopes)
65- })
66- }
67-}
68-
69-func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) {
70- log := tokenTestLogger()
71- h := &TokenHandler{log: log}
72-
73- tests := []struct {
74- name string
75- query string
76- wantScopes []string
77- }{
78- {
79- name: "registry scope is rejected",
80- query: "scope=registry:catalog:*&scope=repository:myrepo:pull",
81- wantScopes: []string{"repository:myrepo:pull"},
82- },
83- {
84- name: "only non-repository scopes returns default",
85- query: "scope=registry:catalog:*&scope=custom:something:action",
86- wantScopes: []string{"repository:*:pull"},
87- },
88- }
89-
90- for _, tt := range tests {
91- t.Run(tt.name, func(t *testing.T) {
92- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
93- scopes := h.parseRequestedScopes(req, log)
94-
95- assert.Equal(t, tt.wantScopes, scopes)
96- })
97- }
98-}
99-
100-func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
101- log := tokenTestLogger()
102- h := &TokenHandler{log: log}
103-
104- req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
105- scopes := h.parseRequestedScopes(req, log)
106-
107- assert.Equal(t, []string{"repository:*:pull"}, scopes)
108-}
109-
110-func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) {
111- log := tokenTestLogger()
112- h := &TokenHandler{log: log}
113-
114- req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil)
115- scopes := h.parseRequestedScopes(req, log)
116-
117- // Empty scope param is invalid and should return default
118- assert.Equal(t, []string{"repository:*:pull"}, scopes)
119-}
120-
121-func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) {
122- log := tokenTestLogger()
123- h := &TokenHandler{log: log}
124-
125- tests := []struct {
126- name string
127- query string
128- wantScopes []string
129- }{
130- {
131- name: "single scope",
132- query: "scope=repository:myrepo:pull",
133- wantScopes: []string{"repository:myrepo:pull"},
134- },
135- {
136- name: "multiple actions",
137- query: "scope=repository:myrepo:push,pull",
138- wantScopes: []string{"repository:myrepo:push,pull"},
139- },
140- {
141- name: "wildcard repo",
142- query: "scope=repository:*:pull",
143- wantScopes: []string{"repository:*:pull"},
144- },
145- {
146- name: "org wildcard",
147- query: "scope=repository:myorg/*:push,pull",
148- wantScopes: []string{"repository:myorg/*:push,pull"},
149- },
150- {
151- name: "nested repo",
152- query: "scope=repository:myorg/team/app:pull",
153- wantScopes: []string{"repository:myorg/team/app:pull"},
154- },
155- }
156-
157- for _, tt := range tests {
158- t.Run(tt.name, func(t *testing.T) {
159- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
160- scopes := h.parseRequestedScopes(req, log)
161-
162- assert.Equal(t, tt.wantScopes, scopes)
163- })
164- }
165-}
166-
167-func TestIsLocalhostRequest(t *testing.T) {
168- tests := []struct {
169- name string
170- remoteAddr string
171- want bool
172- }{
173- {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
174- {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
175- {"::1 is localhost", "[::1]:12345", true},
176- {"::1 without brackets", "::1:12345", true},
177- {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
178- {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
179- {"public IP is not localhost", "8.8.8.8:12345", false},
180- }
181-
182- for _, tt := range tests {
183- t.Run(tt.name, func(t *testing.T) {
184- req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
185- req.RemoteAddr = tt.remoteAddr
186-
187- result := isLocalhostRequest(req)
188-
189- assert.Equal(t, tt.want, result)
190- })
191- }
192-}
@@ -1,192 +0,0 @@
1-package registry
2-
3-import (
4- "net/http"
5- "net/http/httptest"
6- "testing"
7-
8- "github.com/bnema/zerowrap"
9- "github.com/stretchr/testify/assert"
10-)
11-
12-func tokenTestLogger() zerowrap.Logger {
13- return zerowrap.Default()
14-}
15-
16-func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
17- log := tokenTestLogger()
18- h := &TokenHandler{log: log}
19-
20- req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
21-
22- scopes := h.parseRequestedScopes(req, log)
23-
24- assert.Len(t, scopes, 2)
25- assert.Contains(t, scopes, "repository:repo1:pull")
26- assert.Contains(t, scopes, "repository:repo2:push")
27-}
28-
29-func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
30- log := tokenTestLogger()
31- h := &TokenHandler{log: log}
32-
33- tests := []struct {
34- name string
35- query string
36- wantScopes []string
37- wantDefault bool
38- }{
39- {
40- name: "invalid format is skipped",
41- query: "scope=invalid-no-colons&scope=repository:valid:pull",
42- wantScopes: []string{"repository:valid:pull"},
43- wantDefault: false,
44- },
45- {
46- name: "missing actions is skipped",
47- query: "scope=repository:myrepo&scope=repository:valid:pull",
48- wantScopes: []string{"repository:valid:pull"},
49- wantDefault: false,
50- },
51- {
52- name: "all invalid returns default",
53- query: "scope=invalid&scope=also-invalid",
54- wantScopes: []string{"repository:*:pull"},
55- wantDefault: true,
56- },
57- }
58-
59- for _, tt := range tests {
60- t.Run(tt.name, func(t *testing.T) {
61- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
62- scopes := h.parseRequestedScopes(req, log)
63-
64- assert.Equal(t, tt.wantScopes, scopes)
65- })
66- }
67-}
68-
69-func TestParseRequestedScopes_NonRepositoryScopeRejected(t *testing.T) {
70- log := tokenTestLogger()
71- h := &TokenHandler{log: log}
72-
73- tests := []struct {
74- name string
75- query string
76- wantScopes []string
77- }{
78- {
79- name: "registry scope is rejected",
80- query: "scope=registry:catalog:*&scope=repository:myrepo:pull",
81- wantScopes: []string{"repository:myrepo:pull"},
82- },
83- {
84- name: "only non-repository scopes returns default",
85- query: "scope=registry:catalog:*&scope=custom:something:action",
86- wantScopes: []string{"repository:*:pull"},
87- },
88- }
89-
90- for _, tt := range tests {
91- t.Run(tt.name, func(t *testing.T) {
92- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
93- scopes := h.parseRequestedScopes(req, log)
94-
95- assert.Equal(t, tt.wantScopes, scopes)
96- })
97- }
98-}
99-
100-func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
101- log := tokenTestLogger()
102- h := &TokenHandler{log: log}
103-
104- req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
105- scopes := h.parseRequestedScopes(req, log)
106-
107- assert.Equal(t, []string{"repository:*:pull"}, scopes)
108-}
109-
110-func TestParseRequestedScopes_EmptyScopeParamReturnsDefault(t *testing.T) {
111- log := tokenTestLogger()
112- h := &TokenHandler{log: log}
113-
114- req := httptest.NewRequest(http.MethodGet, "/v2/token?scope=", nil)
115- scopes := h.parseRequestedScopes(req, log)
116-
117- // Empty scope param is invalid and should return default
118- assert.Equal(t, []string{"repository:*:pull"}, scopes)
119-}
120-
121-func TestParseRequestedScopes_ValidScopesPreserved(t *testing.T) {
122- log := tokenTestLogger()
123- h := &TokenHandler{log: log}
124-
125- tests := []struct {
126- name string
127- query string
128- wantScopes []string
129- }{
130- {
131- name: "single scope",
132- query: "scope=repository:myrepo:pull",
133- wantScopes: []string{"repository:myrepo:pull"},
134- },
135- {
136- name: "multiple actions",
137- query: "scope=repository:myrepo:push,pull",
138- wantScopes: []string{"repository:myrepo:push,pull"},
139- },
140- {
141- name: "wildcard repo",
142- query: "scope=repository:*:pull",
143- wantScopes: []string{"repository:*:pull"},
144- },
145- {
146- name: "org wildcard",
147- query: "scope=repository:myorg/*:push,pull",
148- wantScopes: []string{"repository:myorg/*:push,pull"},
149- },
150- {
151- name: "nested repo",
152- query: "scope=repository:myorg/team/app:pull",
153- wantScopes: []string{"repository:myorg/team/app:pull"},
154- },
155- }
156-
157- for _, tt := range tests {
158- t.Run(tt.name, func(t *testing.T) {
159- req := httptest.NewRequest(http.MethodGet, "/v2/token?"+tt.query, nil)
160- scopes := h.parseRequestedScopes(req, log)
161-
162- assert.Equal(t, tt.wantScopes, scopes)
163- })
164- }
165-}
166-
167-func TestIsLocalhostRequest(t *testing.T) {
168- tests := []struct {
169- name string
170- remoteAddr string
171- want bool
172- }{
173- {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
174- {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
175- {"::1 is localhost", "[::1]:12345", true},
176- {"::1 without brackets", "::1:12345", true},
177- {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
178- {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
179- {"public IP is not localhost", "8.8.8.8:12345", false},
180- }
181-
182- for _, tt := range tests {
183- t.Run(tt.name, func(t *testing.T) {
184- req := httptest.NewRequest(http.MethodGet, "/v2/token", nil)
185- req.RemoteAddr = tt.remoteAddr
186-
187- result := isLocalhostRequest(req)
188-
189- assert.Equal(t, tt.want, result)
190- })
191- }
192-}

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