feat(auth): add unified /auth endpoints for password and token auth
- commit
- b1d08adc90dc8c381f6856086048d4236078dd87
- author
- bnema · · 2026-01-18 03:16 UTC
- parent
- 0c68ab9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
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 | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | 5 | "encoding/json" | |
| @@ -19,34 +20,157 @@ type InternalAuth struct { | |||
| 19 | 20 | Password string | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 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 { | |
| 24 | 25 | authSvc in.AuthService | |
| 25 | 26 | internalAuth InternalAuth | |
| 26 | 27 | log zerowrap.Logger | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 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{ | |
| 32 | 33 | authSvc: authSvc, | |
| 33 | 34 | internalAuth: internalAuth, | |
| 34 | 35 | log: log, | |
| 35 | 36 | } | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 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"` | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 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) { | |
| 46 | 170 | ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{ | |
| 47 | 171 | zerowrap.FieldLayer: "adapter", | |
| 48 | 172 | zerowrap.FieldAdapter: "http", | |
| 49 | - | zerowrap.FieldHandler: "token", | |
| 173 | + | zerowrap.FieldHandler: "auth", | |
| 50 | 174 | zerowrap.FieldMethod: r.Method, | |
| 51 | 175 | zerowrap.FieldPath: r.URL.Path, | |
| 52 | 176 | }) | |
| @@ -55,7 +179,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 55 | 179 | if r.Method != http.MethodGet { | |
| 56 | 180 | w.Header().Set("Content-Type", "application/json") | |
| 57 | 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 | 183 | return | |
| 60 | 184 | } | |
| 61 | 185 | ||
| @@ -100,12 +224,11 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 100 | 224 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 101 | 225 | w.Header().Set("Content-Type", "application/json") | |
| 102 | 226 | w.WriteHeader(http.StatusUnauthorized) | |
| 103 | - | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Unauthorized"}) | |
| 227 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "unauthorized"}) | |
| 104 | 228 | return | |
| 105 | 229 | } | |
| 106 | 230 | ||
| 107 | 231 | // Parse requested scopes from query parameter | |
| 108 | - | // Format: scope=repository:name:action1,action2 (can appear multiple times) | |
| 109 | 232 | requestedScopes := h.parseRequestedScopes(r, log) | |
| 110 | 233 | ||
| 111 | 234 | // 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 | 237 | log.Error().Err(err).Msg("failed to generate access token") | |
| 115 | 238 | w.Header().Set("Content-Type", "application/json") | |
| 116 | 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 | 241 | return | |
| 119 | 242 | } | |
| 120 | 243 | ||
| @@ -138,7 +261,7 @@ func (h *TokenHandler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 138 | 261 | } | |
| 139 | 262 | ||
| 140 | 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 | 265 | // For anonymous access, we issue a very short-lived token with limited scope | |
| 143 | 266 | response := dto.TokenResponse{ | |
| 144 | 267 | Token: "", // Empty token indicates limited access | |
| @@ -168,8 +291,8 @@ func isLocalhostRequest(r *http.Request) bool { | |||
| 168 | 291 | ||
| 169 | 292 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 170 | 293 | // 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 { | |
| 173 | 296 | scopeParams := r.URL.Query()["scope"] | |
| 174 | 297 | ||
| 175 | 298 | if len(scopeParams) == 0 { | |
| @@ -215,7 +338,7 @@ func (h *TokenHandler) parseRequestedScopes(r *http.Request, log zerowrap.Logger | |||
| 215 | 338 | } | |
| 216 | 339 | ||
| 217 | 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 | 342 | if h.internalAuth.Username == "" || h.internalAuth.Password == "" { | |
| 220 | 343 | return false | |
| 221 | 344 | } | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | package registry | 1 | + | // 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 string | 20 | 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.AuthService | 25 | authSvc in.AuthService | ||
| 25 | internalAuth InternalAuth | 26 | internalAuth InternalAuth | ||
| 26 | log zerowrap.Logger | 27 | 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 | return | 183 | 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 | return | 228 | return | ||
| 105 | } | 229 | } | ||
| 106 | 230 | ||||
| 107 | // Parse requested scopes from query parameter | 231 | // 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 stored | 234 | // 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 | return | 241 | 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 scope | 265 | // 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 access | 267 | 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:actions | 293 | // 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 | 294 | + | // 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 false | 343 | 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 | - | } | |||