detached bc2cbd5

Merge pull request #141 from bnema/security-hardening

Security hardening & token-only auth
commit
bc2cbd57993e0f197b98cdcd8d5b00e6e73b66ac
author
bnema · · 2026-03-22 18:57 UTC
committer
GitHub · · 2026-03-22 18:57 UTC
merge of
68c61a5 + 62a6579 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
31 files · +982 -1030
M docs/cli/auth.md +39 -75
@@ -1,6 +1,6 @@
11 # Authentication Commands
22
3-Manage Gordon server authentication tokens and passwords.
3+Manage Gordon server authentication tokens.
44
55 ## gordon auth
66
@@ -12,14 +12,13 @@ Manage Gordon server authentication tokens and passwords.
1212 | `token generate` | Generate a new JWT token |
1313 | `token list` | List all stored tokens |
1414 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
15-| `password hash` | Generate bcrypt password hash |
1615 | `internal` | Show internal registry credentials |
1716
1817 ---
1918
2019 ## gordon auth login
2120
22-Authenticate with a remote Gordon server using password authentication or a pre-generated token.
21+Authenticate with a remote Gordon server using a pre-generated token.
2322
2423 ```bash
2524 gordon auth login [options]
@@ -30,34 +29,22 @@ gordon auth login [options]
3029 | Option | Description |
3130 |--------|-------------|
3231 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
33-| `-u, --username` | Username for authentication |
34-| `-p, --password` | Password for authentication |
3532 | `-t, --token` | Authentication token to store for the remote |
3633
3734 ### Description
3835
39-This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
36+This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
4037
41-If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
42-
43-The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.
44-
45-> **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`.
38+The token is verified against `/admin/status` on a best-effort basis.
4639
4740 ### Examples
4841
4942 ```bash
50-# Login using active remote
51-gordon auth login
52-
53-# Login to specific remote
54-gordon auth login --remote prod
55-
56-# Pre-fill username
57-gordon auth login --username admin
58-
59-# Store a pre-generated token
43+# Store a token for the active remote
6044 gordon auth login --token <token>
45+
46+# Store a token for a specific remote
47+gordon auth login --remote prod --token <token>
6148 ```
6249
6350 ### Output
@@ -65,26 +52,14 @@ gordon auth login --token <token>
6552 When `pass` is available:
6653
6754 ```text
68-Username: admin
69-Password: ********
70-Authenticating with prod...
71-
72-✓ Authentication successful!
7355 Token stored in pass for remote 'prod'
74-Expires in: 604800 seconds
7556 ```
7657
7758 When `pass` is not available:
7859
7960 ```text
80-Username: admin
81-Password: ********
82-Authenticating with prod...
83-
8461 Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.
85-✓ Authentication successful!
8662 Token stored for remote 'prod'
87-Expires in: 604800 seconds
8863 ```
8964
9065 ---
@@ -121,6 +96,18 @@ Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
12196
12297 Standard Go durations also work: `24h`, `30m`, `1h30m`
12398
99+### Scopes
100+
101+Registry scopes: `push`, `pull`, `push,pull`
102+
103+Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write`
104+
105+Combine scopes with commas:
106+
107+```bash
108+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
109+```
110+
124111 ### Examples
125112
126113 ```bash
@@ -136,8 +123,11 @@ gordon auth token generate --subject builder --scopes push --expiry 1y
136123 # Temporary token (24 hours)
137124 gordon auth token generate --subject temp --expiry 24h
138125
139-# Token for 6 months
140-gordon auth token generate --subject deploy --expiry 6M
126+# Full admin access token
127+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 6M
128+
129+# Read-only admin token
130+gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
141131 ```
142132
143133 ### Output
@@ -199,33 +189,6 @@ Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
199189
200190 ---
201191
202-## gordon auth password hash
203-
204-Generate a bcrypt hash for password authentication.
205-
206-```bash
207-gordon auth password hash
208-```
209-
210-Interactively prompts for a password and outputs the bcrypt hash.
211-
212-### Output
213-
214-```
215-Enter password: ********
216-
217-Bcrypt hash (store in your secrets backend):
218-$2a$10$N9qo8uLOickgx2ZMRZoMye...
219-
220-Then reference the path in your config:
221- [auth]
222- username = "deploy"
223- password_hash = "gordon/auth/password_hash"
224- token_secret = "gordon/auth/token_secret"
225-```
226-
227----
228-
229192 ## gordon auth internal
230193
231194 Display the auto-generated internal registry credentials.
@@ -272,6 +235,14 @@ Usage:
272235 | `push` | Push images to registry |
273236 | `pull` | Pull images from registry |
274237 | `push,pull` | Both push and pull (default) |
238+| `admin:*:*` | Full admin access |
239+| `admin:routes:read` | Read-only routes access |
240+| `admin:routes:write` | Routes write access |
241+| `admin:config:read` | Read-only config access |
242+| `admin:config:write` | Config write access |
243+| `admin:status:read` | Read-only status/health |
244+| `admin:secrets:read` | List secret keys |
245+| `admin:secrets:write` | Set/delete secrets |
275246
276247 ## Token Expiry Formats
277248
@@ -293,8 +264,8 @@ Usage:
293264 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
294265
295266 # Save token as CI secret
296-# GitHub: Settings → Secrets → New repository secret
297-# GitLab: Settings → CI/CD → Variables
267+# GitHub: Settings > Secrets > New repository secret
268+# GitLab: Settings > CI/CD > Variables
298269 ```
299270
300271 ### Docker Login
@@ -319,17 +290,9 @@ gordon auth token generate --subject ci-bot --expiry 0
319290 gordon auth token revoke <old-token-id>
320291 ```
321292
322-### Password Setup
293+### Token-only Setup
323294
324295 ```bash
325-# Generate hash
326-gordon auth password hash
327-# Enter: mypassword
328-
329-# Store in pass
330-pass insert gordon/auth/password_hash
331-# Paste: $2a$10$...
332-
333296 # Create token secret
334297 pass insert gordon/auth/token_secret
335298 # Enter a random 32+ character string
@@ -338,9 +301,10 @@ pass insert gordon/auth/token_secret
338301 [auth]
339302 enabled = true
340303 secrets_backend = "pass"
341-username = "deploy"
342-password_hash = "gordon/auth/password_hash"
343304 token_secret = "gordon/auth/token_secret"
305+
306+# Generate a deploy token
307+gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0
344308 ```
345309
346310 ## Related
@@ -1,6 +1,6 @@
1 # Authentication Commands1 # Authentication Commands
2 2
3-Manage Gordon server authentication tokens and passwords.3+Manage Gordon server authentication tokens.
4 4
5 ## gordon auth5 ## gordon auth
6 6
@@ -12,14 +12,13 @@ Manage Gordon server authentication tokens and passwords.
12 | `token generate` | Generate a new JWT token |12 | `token generate` | Generate a new JWT token |
13 | `token list` | List all stored tokens |13 | `token list` | List all stored tokens |
14 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |14 | `token revoke` | Revoke a token by ID (use `--all` to revoke all tokens) |
15-| `password hash` | Generate bcrypt password hash |
16 | `internal` | Show internal registry credentials |15 | `internal` | Show internal registry credentials |
17 16
18 ---17 ---
19 18
20 ## gordon auth login19 ## gordon auth login
21 20
22-Authenticate with a remote Gordon server using password authentication or a pre-generated token.21+Authenticate with a remote Gordon server using a pre-generated token.
23 22
24 ```bash23 ```bash
25 gordon auth login [options]24 gordon auth login [options]
@@ -30,34 +29,22 @@ gordon auth login [options]
30 | Option | Description |29 | Option | Description |
31 |--------|-------------|30 |--------|-------------|
32 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |31 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
33-| `-u, --username` | Username for authentication |
34-| `-p, --password` | Password for authentication |
35 | `-t, --token` | Authentication token to store for the remote |32 | `-t, --token` | Authentication token to store for the remote |
36 33
37 ### Description34 ### Description
38 35
39-This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.36+This command stores a pre-generated token for use with a remote Gordon server. When [pass](https://www.passwordstore.org/) is available, the token is stored encrypted at `gordon/remotes/<name>/token` and any plaintext token fields are cleared from `remotes.toml`. When `pass` is unavailable, the token is stored in plaintext in `remotes.toml` with a warning.
40 37
41-If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.38+The token is verified against `/admin/status` on a best-effort basis.
42-
43-The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.
44-
45-> **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`.
46 39
47 ### Examples40 ### Examples
48 41
49 ```bash42 ```bash
50-# Login using active remote43+# Store a token for the active remote
51-gordon auth login
52-
53-# Login to specific remote
54-gordon auth login --remote prod
55-
56-# Pre-fill username
57-gordon auth login --username admin
58-
59-# Store a pre-generated token
60 gordon auth login --token <token>44 gordon auth login --token <token>
45+
46+# Store a token for a specific remote
47+gordon auth login --remote prod --token <token>
61 ```48 ```
62 49
63 ### Output50 ### Output
@@ -65,26 +52,14 @@ gordon auth login --token <token>
65 When `pass` is available:52 When `pass` is available:
66 53
67 ```text54 ```text
68-Username: admin
69-Password: ********
70-Authenticating with prod...
71-
72-✓ Authentication successful!
73 Token stored in pass for remote 'prod'55 Token stored in pass for remote 'prod'
74-Expires in: 604800 seconds
75 ```56 ```
76 57
77 When `pass` is not available:58 When `pass` is not available:
78 59
79 ```text60 ```text
80-Username: admin
81-Password: ********
82-Authenticating with prod...
83-
84 Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.61 Warning: 'pass' not available. Storing token in plaintext config. Consider installing pass (https://www.passwordstore.org/) for secure token storage.
85-✓ Authentication successful!
86 Token stored for remote 'prod'62 Token stored for remote 'prod'
87-Expires in: 604800 seconds
88 ```63 ```
89 64
90 ---65 ---
@@ -121,6 +96,18 @@ Compound durations are also supported: `1y6M`, `2w3d`, `1d12h`
121 96
122 Standard Go durations also work: `24h`, `30m`, `1h30m`97 Standard Go durations also work: `24h`, `30m`, `1h30m`
123 98
99+### Scopes
100+
101+Registry scopes: `push`, `pull`, `push,pull`
102+
103+Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:secrets:read`, `admin:secrets:write`
104+
105+Combine scopes with commas:
106+
107+```bash
108+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
109+```
110+
124 ### Examples111 ### Examples
125 112
126 ```bash113 ```bash
@@ -136,8 +123,11 @@ gordon auth token generate --subject builder --scopes push --expiry 1y
136 # Temporary token (24 hours)123 # Temporary token (24 hours)
137 gordon auth token generate --subject temp --expiry 24h124 gordon auth token generate --subject temp --expiry 24h
138 125
139-# Token for 6 months126+# Full admin access token
140-gordon auth token generate --subject deploy --expiry 6M127+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 6M
128+
129+# Read-only admin token
130+gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
141 ```131 ```
142 132
143 ### Output133 ### Output
@@ -199,33 +189,6 @@ Token a1b2c3d4-e5f6-7890-abcd-ef1234567890 has been revoked.
199 189
200 ---190 ---
201 191
202-## gordon auth password hash
203-
204-Generate a bcrypt hash for password authentication.
205-
206-```bash
207-gordon auth password hash
208-```
209-
210-Interactively prompts for a password and outputs the bcrypt hash.
211-
212-### Output
213-
214-```
215-Enter password: ********
216-
217-Bcrypt hash (store in your secrets backend):
218-$2a$10$N9qo8uLOickgx2ZMRZoMye...
219-
220-Then reference the path in your config:
221- [auth]
222- username = "deploy"
223- password_hash = "gordon/auth/password_hash"
224- token_secret = "gordon/auth/token_secret"
225-```
226-
227----
228-
229 ## gordon auth internal192 ## gordon auth internal
230 193
231 Display the auto-generated internal registry credentials.194 Display the auto-generated internal registry credentials.
@@ -272,6 +235,14 @@ Usage:
272 | `push` | Push images to registry |235 | `push` | Push images to registry |
273 | `pull` | Pull images from registry |236 | `pull` | Pull images from registry |
274 | `push,pull` | Both push and pull (default) |237 | `push,pull` | Both push and pull (default) |
238+| `admin:*:*` | Full admin access |
239+| `admin:routes:read` | Read-only routes access |
240+| `admin:routes:write` | Routes write access |
241+| `admin:config:read` | Read-only config access |
242+| `admin:config:write` | Config write access |
243+| `admin:status:read` | Read-only status/health |
244+| `admin:secrets:read` | List secret keys |
245+| `admin:secrets:write` | Set/delete secrets |
275 246
276 ## Token Expiry Formats247 ## Token Expiry Formats
277 248
@@ -293,8 +264,8 @@ Usage:
293 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0264 gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
294 265
295 # Save token as CI secret266 # Save token as CI secret
296-# GitHub: Settings → Secrets → New repository secret267+# GitHub: Settings > Secrets > New repository secret
297-# GitLab: Settings → CI/CD → Variables268+# GitLab: Settings > CI/CD > Variables
298 ```269 ```
299 270
300 ### Docker Login271 ### Docker Login
@@ -319,17 +290,9 @@ gordon auth token generate --subject ci-bot --expiry 0
319 gordon auth token revoke <old-token-id>290 gordon auth token revoke <old-token-id>
320 ```291 ```
321 292
322-### Password Setup293+### Token-only Setup
323 294
324 ```bash295 ```bash
325-# Generate hash
326-gordon auth password hash
327-# Enter: mypassword
328-
329-# Store in pass
330-pass insert gordon/auth/password_hash
331-# Paste: $2a$10$...
332-
333 # Create token secret296 # Create token secret
334 pass insert gordon/auth/token_secret297 pass insert gordon/auth/token_secret
335 # Enter a random 32+ character string298 # Enter a random 32+ character string
@@ -338,9 +301,10 @@ pass insert gordon/auth/token_secret
338 [auth]301 [auth]
339 enabled = true302 enabled = true
340 secrets_backend = "pass"303 secrets_backend = "pass"
341-username = "deploy"
342-password_hash = "gordon/auth/password_hash"
343 token_secret = "gordon/auth/token_secret"304 token_secret = "gordon/auth/token_secret"
305+
306+# Generate a deploy token
307+gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0
344 ```308 ```
345 309
346 ## Related310 ## Related
M docs/config/auth.md +24 -40
@@ -4,29 +4,18 @@ Configure authentication for the Gordon registry and Admin API.
44
55 Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`).
66
7-## Pick a Mode
7+## Configuration
88
9-### Token-only (recommended for CI/CD)
9+Gordon uses token-only authentication. All registry and Admin API access is controlled through JWT tokens.
1010
1111 ```toml
1212 [auth]
1313 secrets_backend = "pass"
1414 token_secret = "gordon/auth/token_secret"
1515 token_expiry = "30d"
16+access_token_ttl = "15m"
1617 ```
1718
18-### Password + Token (interactive + CI/CD)
19-
20-```toml
21-[auth]
22-secrets_backend = "pass"
23-username = "deploy"
24-password_hash = "gordon/auth/password_hash"
25-token_secret = "gordon/auth/token_secret"
26-```
27-
28-Token auth always works. Adding `username` + `password_hash` enables interactive login.
29-
3019 ## Options
3120
3221 | Option | Type | Default | Description |
@@ -35,12 +24,11 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
3524 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
3625 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
3726 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
38-| `username` | string | - | Username for password auth (optional) |
39-| `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) |
27+| `access_token_ttl` | string | `"15m"` | Lifetime of ephemeral access tokens issued by `/auth/token` |
4028
4129 ## Secrets Backends
4230
43-`token_secret` and `password_hash` are read through the configured backend:
31+`token_secret` is read through the configured backend:
4432
4533 | Backend | Description |
4634 | --------- | ------------- |
@@ -52,7 +40,7 @@ See [Secret Providers](./secrets.md) for setup details.
5240
5341 ## Environment Variable
5442
55-`GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`).
43+`GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don't set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`).
5644
5745 ```bash
5846 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
@@ -61,8 +49,6 @@ gordon serve
6149
6250 ## Setup
6351
64-### Token-only setup
65-
6652 ```bash
6753 # Create token secret (random 32+ characters)
6854 pass insert gordon/auth/token_secret
@@ -76,28 +62,9 @@ gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
7662 docker login -u ci-bot -p <token> registry.mydomain.com
7763 ```
7864
79-### Password + token setup
80-
81-```bash
82-# Generate bcrypt hash
83-gordon auth password hash
84-
85-# Store hash and token secret
86-pass insert gordon/auth/password_hash
87-pass insert gordon/auth/token_secret
88-```
89-
90-```bash
91-# Docker registry access
92-docker login -u deploy -p <password> registry.mydomain.com
93-
94-# Remote CLI login (stores a token for you)
95-gordon auth login --remote prod
96-```
97-
9865 ## Remote CLI Tokens
9966
100-If you already have a token (token-only servers), store it directly:
67+Store a token for remote CLI access:
10168
10269 ```bash
10370 gordon auth login --token <token>
@@ -136,6 +103,23 @@ gordon auth token generate --subject builder --scopes push --expiry 0
136103 gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
137104 ```
138105
106+## Access Token TTL
107+
108+The `access_token_ttl` setting controls the lifetime of ephemeral access tokens issued by the `/auth/token` endpoint. These short-lived tokens are used internally for registry operations and Admin API sessions.
109+
110+| Value | Meaning |
111+|-------|---------|
112+| `"15m"` | 15 minutes (default) |
113+| `"30m"` | 30 minutes |
114+| `"1h"` | 1 hour (maximum before store validation applies) |
115+
116+Tokens with a lifetime of 1 hour or less skip store validation for performance. Longer-lived tokens (generated via `gordon auth token generate`) are always validated against the token store.
117+
118+```toml
119+[auth]
120+access_token_ttl = "30m"
121+```
122+
139123 ## Token Expiry
140124
141125 Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`.
@@ -4,29 +4,18 @@ Configure authentication for the Gordon registry and Admin API.
4 4
5 Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`).5 Authentication is enabled by default. Gordon will not start without a valid `token_secret` (or `GORDON_AUTH_TOKEN_SECRET`).
6 6
7-## Pick a Mode7+## Configuration
8 8
9-### Token-only (recommended for CI/CD)9+Gordon uses token-only authentication. All registry and Admin API access is controlled through JWT tokens.
10 10
11 ```toml11 ```toml
12 [auth]12 [auth]
13 secrets_backend = "pass"13 secrets_backend = "pass"
14 token_secret = "gordon/auth/token_secret"14 token_secret = "gordon/auth/token_secret"
15 token_expiry = "30d"15 token_expiry = "30d"
16+access_token_ttl = "15m"
16 ```17 ```
17 18
18-### Password + Token (interactive + CI/CD)
19-
20-```toml
21-[auth]
22-secrets_backend = "pass"
23-username = "deploy"
24-password_hash = "gordon/auth/password_hash"
25-token_secret = "gordon/auth/token_secret"
26-```
27-
28-Token auth always works. Adding `username` + `password_hash` enables interactive login.
29-
30 ## Options19 ## Options
31 20
32 | Option | Type | Default | Description |21 | Option | Type | Default | Description |
@@ -35,12 +24,11 @@ Token auth always works. Adding `username` + `password_hash` enables interactive
35 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |24 | `secrets_backend` | string | `"unsafe"` | Secrets backend: `"pass"`, `"sops"`, or `"unsafe"` |
36 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |25 | `token_secret` | string | - | **Required.** Path to JWT signing secret in secrets backend |
37 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |26 | `token_expiry` | string | `"30d"` | Token validity duration (0 = never expires) |
38-| `username` | string | - | Username for password auth (optional) |27+| `access_token_ttl` | string | `"15m"` | Lifetime of ephemeral access tokens issued by `/auth/token` |
39-| `password_hash` | string | - | Path to bcrypt hash in secrets backend (optional) |
40 28
41 ## Secrets Backends29 ## Secrets Backends
42 30
43-`token_secret` and `password_hash` are read through the configured backend:31+`token_secret` is read through the configured backend:
44 32
45 | Backend | Description |33 | Backend | Description |
46 | --------- | ------------- |34 | --------- | ------------- |
@@ -52,7 +40,7 @@ See [Secret Providers](./secrets.md) for setup details.
52 40
53 ## Environment Variable41 ## Environment Variable
54 42
55-`GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don’t set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`).43+`GORDON_AUTH_TOKEN_SECRET` overrides `token_secret` in the config. If you don't set the env var, `token_secret` is loaded from the configured secrets backend (including `pass`).
56 44
57 ```bash45 ```bash
58 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"46 export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here"
@@ -61,8 +49,6 @@ gordon serve
61 49
62 ## Setup50 ## Setup
63 51
64-### Token-only setup
65-
66 ```bash52 ```bash
67 # Create token secret (random 32+ characters)53 # Create token secret (random 32+ characters)
68 pass insert gordon/auth/token_secret54 pass insert gordon/auth/token_secret
@@ -76,28 +62,9 @@ gordon auth token generate --subject ci-bot --scopes push,pull --expiry 0
76 docker login -u ci-bot -p <token> registry.mydomain.com62 docker login -u ci-bot -p <token> registry.mydomain.com
77 ```63 ```
78 64
79-### Password + token setup
80-
81-```bash
82-# Generate bcrypt hash
83-gordon auth password hash
84-
85-# Store hash and token secret
86-pass insert gordon/auth/password_hash
87-pass insert gordon/auth/token_secret
88-```
89-
90-```bash
91-# Docker registry access
92-docker login -u deploy -p <password> registry.mydomain.com
93-
94-# Remote CLI login (stores a token for you)
95-gordon auth login --remote prod
96-```
97-
98 ## Remote CLI Tokens65 ## Remote CLI Tokens
99 66
100-If you already have a token (token-only servers), store it directly:67+Store a token for remote CLI access:
101 68
102 ```bash69 ```bash
103 gordon auth login --token <token>70 gordon auth login --token <token>
@@ -136,6 +103,23 @@ gordon auth token generate --subject builder --scopes push --expiry 0
136 gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0103 gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
137 ```104 ```
138 105
106+## Access Token TTL
107+
108+The `access_token_ttl` setting controls the lifetime of ephemeral access tokens issued by the `/auth/token` endpoint. These short-lived tokens are used internally for registry operations and Admin API sessions.
109+
110+| Value | Meaning |
111+|-------|---------|
112+| `"15m"` | 15 minutes (default) |
113+| `"30m"` | 30 minutes |
114+| `"1h"` | 1 hour (maximum before store validation applies) |
115+
116+Tokens with a lifetime of 1 hour or less skip store validation for performance. Longer-lived tokens (generated via `gordon auth token generate`) are always validated against the token store.
117+
118+```toml
119+[auth]
120+access_token_ttl = "30m"
121+```
122+
139 ## Token Expiry123 ## Token Expiry
140 124
141 Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`.125 Durations support `d`, `w`, `M`, `y` and combinations like `1y6M` or `2w3d`.
M docs/config/reference.md +2 -3
@@ -28,9 +28,7 @@ enabled = true # Enable registry authentication (d
2828 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"
2929 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)
3030 token_expiry = "720h" # Token expiry duration (720h = 30 days)
31-# Optional: enable password authentication (for interactive login)
32-# username = "" # Username for password auth
33-# password_hash = "" # Path in secrets backend to bcrypt hash
31+access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)
3432
3533 # =============================================================================
3634 # API (applies to both Registry and Admin endpoints)
@@ -182,6 +180,7 @@ keep_last = 3 # Keep N newest tags per repository
182180 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
183181 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
184182 | `auth.token_expiry` | `"720h"` | 30 days |
183+| `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
185184 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
186185 | `api.rate_limit.global_rps` | `500` | Global requests/second |
187186 | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
@@ -28,9 +28,7 @@ enabled = true # Enable registry authentication (d
28 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"28 secrets_backend = "unsafe" # "pass", "sops", or "unsafe"
29 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)29 token_secret = "" # Path in secrets backend to JWT signing key (REQUIRED)
30 token_expiry = "720h" # Token expiry duration (720h = 30 days)30 token_expiry = "720h" # Token expiry duration (720h = 30 days)
31-# Optional: enable password authentication (for interactive login)31+access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m)
32-# username = "" # Username for password auth
33-# password_hash = "" # Path in secrets backend to bcrypt hash
34 32
35 # =============================================================================33 # =============================================================================
36 # API (applies to both Registry and Admin endpoints)34 # API (applies to both Registry and Admin endpoints)
@@ -182,6 +180,7 @@ keep_last = 3 # Keep N newest tags per repository
182 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |180 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
183 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |181 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
184 | `auth.token_expiry` | `"720h"` | 30 days |182 | `auth.token_expiry` | `"720h"` | 30 days |
183+| `auth.access_token_ttl` | `"15m"` | Ephemeral access token lifetime |
185 | `api.rate_limit.enabled` | `true` | Enable rate limiting |184 | `api.rate_limit.enabled` | `true` | Enable rate limiting |
186 | `api.rate_limit.global_rps` | `500` | Global requests/second |185 | `api.rate_limit.global_rps` | `500` | Global requests/second |
187 | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |186 | `api.rate_limit.per_ip_rps` | `50` | Per-IP requests/second |
M docs/getting-started.md +1 -1
@@ -132,7 +132,7 @@ gordon auth token generate --subject deploy --scopes push,pull --expiry 0
132132
133133 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.
134134
135-Save this token securely - you'll use it with `gordon --remote` (or a saved remote), and it should be limited to remote deploy use.
135+Save this token securely -- you will use it to authenticate with your Gordon server from CI/CD pipelines and remote CLI sessions.
136136
137137 ## 8. Deploy Your First App
138138
@@ -132,7 +132,7 @@ gordon auth token generate --subject deploy --scopes push,pull --expiry 0
132 132
133 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.133 `--expiry 0` creates a non-expiring token. Prefer a finite expiry and a rotation policy unless you explicitly need a long-lived deploy token.
134 134
135-Save this token securely - you'll use it with `gordon --remote` (or a saved remote), and it should be limited to remote deploy use.135+Save this token securely -- you will use it to authenticate with your Gordon server from CI/CD pipelines and remote CLI sessions.
136 136
137 ## 8. Deploy Your First App137 ## 8. Deploy Your First App
138 138
M docs/upgrading.md +103 -0
@@ -2,6 +2,109 @@
22
33 This guide covers breaking changes and migration steps between major versions.
44
5+## v2.16.0 to v2.30.0
6+
7+### Breaking: Password Authentication Removed
8+
9+Gordon v2.30.0 removes password-based authentication entirely. Only token-based authentication is supported.
10+
11+**What changed:**
12+
13+- `auth.type = "password"` is no longer accepted
14+- `auth.password` and `auth.password_hash` config fields are removed
15+- The `gordon auth password hash` CLI command is removed
16+- The `gordon auth login` command no longer accepts `--username` or `--password` flags
17+- The `/auth/password` endpoint now returns `410 Gone`
18+
19+**New features:**
20+
21+- `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
22+- Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
23+
24+**Migration steps:**
25+
26+1. **Before upgrading**, generate a token on your current Gordon instance:
27+
28+ ```bash
29+ gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0
30+ ```
31+
32+ Save this token securely. You will need it after upgrading. Admin scopes (`admin:*:*`) are only available after upgrading to v2.30.0 — regenerate your token with admin scopes after the upgrade if needed.
33+
34+2. **Update remotes** to use the generated token:
35+
36+ ```bash
37+ gordon auth login --token <token>
38+ # or
39+ gordon remotes set-token prod <token>
40+ ```
41+
42+3. **Update your config** to remove password fields:
43+
44+ **Before (v2.16.0):**
45+
46+ ```toml
47+ [auth]
48+ enabled = true
49+ secrets_backend = "pass"
50+ username = "deploy"
51+ password_hash = "gordon/auth/password_hash"
52+ token_secret = "gordon/auth/token_secret"
53+ ```
54+
55+ **After (v2.30.0):**
56+
57+ ```toml
58+ [auth]
59+ enabled = true
60+ secrets_backend = "pass"
61+ token_secret = "gordon/auth/token_secret"
62+ access_token_ttl = "15m"
63+ ```
64+
65+4. **Update CI/CD pipelines** to use `GORDON_TOKEN` environment variable for authentication. See the [deployment guides](./deployment/index.md).
66+
67+5. **Upgrade the binary** and restart:
68+
69+ ```bash
70+ curl -fsSL https://gordon.bnema.dev/install | bash
71+ systemctl --user restart gordon
72+ ```
73+
74+6. **Verify** the server starts without errors:
75+
76+ ```bash
77+ journalctl --user -u gordon -f
78+ ```
79+
80+### New: Configurable Access Token TTL
81+
82+Ephemeral access tokens issued by `/auth/token` now have a configurable lifetime via `auth.access_token_ttl` (default `"15m"`, maximum `"1h"`).
83+
84+Tokens with a lifetime at or below `MaxAccessTokenLifetime` (1 hour) are treated as ephemeral: they skip token store validation for performance, which means they **cannot be individually revoked**. They become invalid only when they naturally expire. Shortening `auth.access_token_ttl` only reduces the exposure window; it does not enable per-token revocation. If you need explicit revocation, use a stored long-lived token instead of `/auth/token`.
85+
86+```toml
87+[auth]
88+access_token_ttl = "30m"
89+```
90+
91+### New: Admin Scopes
92+
93+Tokens can now include fine-grained admin scopes for remote CLI operations:
94+
95+```bash
96+# Full admin access
97+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
98+
99+# Read-only monitoring
100+gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
101+
102+# CI deploy with route read + config write
103+gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0
104+```
105+
106+See [Token Scopes](./config/auth.md#token-scopes) for the full list.
107+
5108 ## v2.6.0 to v2.7.0
6109
7110 ### Breaking: Token Secret Required
@@ -2,6 +2,109 @@
2 2
3 This guide covers breaking changes and migration steps between major versions.3 This guide covers breaking changes and migration steps between major versions.
4 4
5+## v2.16.0 to v2.30.0
6+
7+### Breaking: Password Authentication Removed
8+
9+Gordon v2.30.0 removes password-based authentication entirely. Only token-based authentication is supported.
10+
11+**What changed:**
12+
13+- `auth.type = "password"` is no longer accepted
14+- `auth.password` and `auth.password_hash` config fields are removed
15+- The `gordon auth password hash` CLI command is removed
16+- The `gordon auth login` command no longer accepts `--username` or `--password` flags
17+- The `/auth/password` endpoint now returns `410 Gone`
18+
19+**New features:**
20+
21+- `auth.access_token_ttl` configures the lifetime of ephemeral access tokens issued by `/auth/token` (default: `"15m"`)
22+- Admin scopes (`admin:*:*`, `admin:routes:read`, etc.) allow fine-grained access control for remote CLI operations
23+
24+**Migration steps:**
25+
26+1. **Before upgrading**, generate a token on your current Gordon instance:
27+
28+ ```bash
29+ gordon auth token generate --subject deploy --scopes "push,pull" --expiry 0
30+ ```
31+
32+ Save this token securely. You will need it after upgrading. Admin scopes (`admin:*:*`) are only available after upgrading to v2.30.0 — regenerate your token with admin scopes after the upgrade if needed.
33+
34+2. **Update remotes** to use the generated token:
35+
36+ ```bash
37+ gordon auth login --token <token>
38+ # or
39+ gordon remotes set-token prod <token>
40+ ```
41+
42+3. **Update your config** to remove password fields:
43+
44+ **Before (v2.16.0):**
45+
46+ ```toml
47+ [auth]
48+ enabled = true
49+ secrets_backend = "pass"
50+ username = "deploy"
51+ password_hash = "gordon/auth/password_hash"
52+ token_secret = "gordon/auth/token_secret"
53+ ```
54+
55+ **After (v2.30.0):**
56+
57+ ```toml
58+ [auth]
59+ enabled = true
60+ secrets_backend = "pass"
61+ token_secret = "gordon/auth/token_secret"
62+ access_token_ttl = "15m"
63+ ```
64+
65+4. **Update CI/CD pipelines** to use `GORDON_TOKEN` environment variable for authentication. See the [deployment guides](./deployment/index.md).
66+
67+5. **Upgrade the binary** and restart:
68+
69+ ```bash
70+ curl -fsSL https://gordon.bnema.dev/install | bash
71+ systemctl --user restart gordon
72+ ```
73+
74+6. **Verify** the server starts without errors:
75+
76+ ```bash
77+ journalctl --user -u gordon -f
78+ ```
79+
80+### New: Configurable Access Token TTL
81+
82+Ephemeral access tokens issued by `/auth/token` now have a configurable lifetime via `auth.access_token_ttl` (default `"15m"`, maximum `"1h"`).
83+
84+Tokens with a lifetime at or below `MaxAccessTokenLifetime` (1 hour) are treated as ephemeral: they skip token store validation for performance, which means they **cannot be individually revoked**. They become invalid only when they naturally expire. Shortening `auth.access_token_ttl` only reduces the exposure window; it does not enable per-token revocation. If you need explicit revocation, use a stored long-lived token instead of `/auth/token`.
85+
86+```toml
87+[auth]
88+access_token_ttl = "30m"
89+```
90+
91+### New: Admin Scopes
92+
93+Tokens can now include fine-grained admin scopes for remote CLI operations:
94+
95+```bash
96+# Full admin access
97+gordon auth token generate --subject admin --scopes "push,pull,admin:*:*" --expiry 0
98+
99+# Read-only monitoring
100+gordon auth token generate --subject monitor --scopes "admin:status:read" --expiry 30d
101+
102+# CI deploy with route read + config write
103+gordon auth token generate --subject ci --scopes "push,pull,admin:routes:read,admin:config:write" --expiry 0
104+```
105+
106+See [Token Scopes](./config/auth.md#token-scopes) for the full list.
107+
5 ## v2.6.0 to v2.7.0108 ## v2.6.0 to v2.7.0
6 109
7 ### Breaking: Token Secret Required110 ### Breaking: Token Secret Required
M go.mod +0 -1
@@ -22,7 +22,6 @@ require (
2222 go.opentelemetry.io/otel/sdk v1.42.0
2323 go.opentelemetry.io/otel/sdk/log v0.18.0
2424 go.opentelemetry.io/otel/sdk/metric v1.42.0
25- golang.org/x/crypto v0.49.0
2625 golang.org/x/mod v0.34.0
2726 golang.org/x/term v0.41.0
2827 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -22,7 +22,6 @@ require (
22 go.opentelemetry.io/otel/sdk v1.42.022 go.opentelemetry.io/otel/sdk v1.42.0
23 go.opentelemetry.io/otel/sdk/log v0.18.023 go.opentelemetry.io/otel/sdk/log v0.18.0
24 go.opentelemetry.io/otel/sdk/metric v1.42.024 go.opentelemetry.io/otel/sdk/metric v1.42.0
25- golang.org/x/crypto v0.49.0
26 golang.org/x/mod v0.34.025 golang.org/x/mod v0.34.0
27 golang.org/x/term v0.41.026 golang.org/x/term v0.41.0
28 gopkg.in/natefinch/lumberjack.v2 v2.2.127 gopkg.in/natefinch/lumberjack.v2 v2.2.1
M go.sum +0 -2
@@ -208,8 +208,6 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
208208 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
209209 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
210210 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
211-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
212-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
213211 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
214212 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
215213 golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -208,8 +208,6 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
208 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=208 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
209 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=209 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
210 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=210 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
211-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
212-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
213 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=211 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
214 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=212 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
215 golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=213 golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
M internal/adapters/in/cli/auth.go +1 -78
@@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command {
4444 }
4545
4646 cmd.AddCommand(newAuthTokenCmd())
47- cmd.AddCommand(newAuthPasswordCmd())
4847 cmd.AddCommand(newAuthInternalCmd())
4948 cmd.AddCommand(newAuthLoginCmd())
5049 cmd.AddCommand(newAuthStatusCmd())
@@ -191,19 +190,6 @@ Examples:
191190 return cmd
192191 }
193192
194-// newAuthPasswordCmd creates the password subcommand group.
195-func newAuthPasswordCmd() *cobra.Command {
196- cmd := &cobra.Command{
197- Use: "password",
198- Short: "Password utilities",
199- Long: `Utilities for managing password authentication.`,
200- }
201-
202- cmd.AddCommand(newPasswordHashCmd())
203-
204- return cmd
205-}
206-
207193 // newAuthInternalCmd creates the internal credentials command.
208194 func newAuthInternalCmd() *cobra.Command {
209195 return &cobra.Command{
@@ -420,24 +406,6 @@ func runShowInternalAuth() error {
420406 return nil
421407 }
422408
423-// newPasswordHashCmd creates the password hash command.
424-func newPasswordHashCmd() *cobra.Command {
425- return &cobra.Command{
426- Use: "hash",
427- Short: "Generate a bcrypt hash for a password",
428- Long: `Interactively prompt for a password and output its bcrypt hash.
429-
430-The hash can be stored in your secrets backend and referenced in the config:
431-
432- [auth]
433- type = "password"
434- password_hash = "github.com/bnema/gordon/auth/password_hash"`,
435- RunE: func(cmd *cobra.Command, args []string) error {
436- return runPasswordHash()
437- },
438- }
439-}
440-
441409 // runTokenGenerate generates a new authentication token.
442410 // parseAndConvertScopes parses a comma-separated scope string and converts
443411 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
@@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error {
646614 return nil
647615 }
648616
649-// runPasswordHash generates a bcrypt hash for a password.
650-func runPasswordHash() error {
651- fmt.Print("Enter password: ")
652-
653- // Read password without echo (use os.Stdin.Fd() for better compatibility)
654- fd, err := stdinFD()
655- if err != nil {
656- return fmt.Errorf("failed to get stdin fd: %w", err)
657- }
658- passwordBytes, err := term.ReadPassword(fd)
659- if err != nil {
660- // Fallback for non-terminal input
661- reader := bufio.NewReader(os.Stdin)
662- password, err := reader.ReadString('\n')
663- if err != nil {
664- return fmt.Errorf("failed to read password: %w", err)
665- }
666- passwordBytes = []byte(strings.TrimSpace(password))
667- }
668- fmt.Println()
669-
670- if len(passwordBytes) == 0 {
671- return fmt.Errorf("password cannot be empty")
672- }
673-
674- // Create a temporary auth service to generate the hash
675- authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
676- hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
677- if err != nil {
678- return fmt.Errorf("failed to generate hash: %w", err)
679- }
680-
681- fmt.Println()
682- fmt.Println("Bcrypt hash (store in your secrets backend):")
683- fmt.Println(hash)
684- fmt.Println()
685- fmt.Println("Then reference the path in your config:")
686- fmt.Println(" [auth]")
687- fmt.Println(" type = \"password\"")
688- fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
689-
690- return nil
691-}
692-
693617 // cliConfig holds the configuration needed for CLI commands.
694618 type cliConfig struct {
695619 Backend domain.SecretsBackend
@@ -748,8 +672,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
748672 cfg.TokenSecret = []byte(tokenSecretPath)
749673 }
750674 } else {
751- // Generate a default secret for CLI token generation
752- cfg.TokenSecret = []byte("gordon-cli-default-secret")
675+ return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
753676 }
754677
755678 return cfg, nil
@@ -44,7 +44,6 @@ func newAuthCmd() *cobra.Command {
44 }44 }
45 45
46 cmd.AddCommand(newAuthTokenCmd())46 cmd.AddCommand(newAuthTokenCmd())
47- cmd.AddCommand(newAuthPasswordCmd())
48 cmd.AddCommand(newAuthInternalCmd())47 cmd.AddCommand(newAuthInternalCmd())
49 cmd.AddCommand(newAuthLoginCmd())48 cmd.AddCommand(newAuthLoginCmd())
50 cmd.AddCommand(newAuthStatusCmd())49 cmd.AddCommand(newAuthStatusCmd())
@@ -191,19 +190,6 @@ Examples:
191 return cmd190 return cmd
192 }191 }
193 192
194-// newAuthPasswordCmd creates the password subcommand group.
195-func newAuthPasswordCmd() *cobra.Command {
196- cmd := &cobra.Command{
197- Use: "password",
198- Short: "Password utilities",
199- Long: `Utilities for managing password authentication.`,
200- }
201-
202- cmd.AddCommand(newPasswordHashCmd())
203-
204- return cmd
205-}
206-
207 // newAuthInternalCmd creates the internal credentials command.193 // newAuthInternalCmd creates the internal credentials command.
208 func newAuthInternalCmd() *cobra.Command {194 func newAuthInternalCmd() *cobra.Command {
209 return &cobra.Command{195 return &cobra.Command{
@@ -420,24 +406,6 @@ func runShowInternalAuth() error {
420 return nil406 return nil
421 }407 }
422 408
423-// newPasswordHashCmd creates the password hash command.
424-func newPasswordHashCmd() *cobra.Command {
425- return &cobra.Command{
426- Use: "hash",
427- Short: "Generate a bcrypt hash for a password",
428- Long: `Interactively prompt for a password and output its bcrypt hash.
429-
430-The hash can be stored in your secrets backend and referenced in the config:
431-
432- [auth]
433- type = "password"
434- password_hash = "github.com/bnema/gordon/auth/password_hash"`,
435- RunE: func(cmd *cobra.Command, args []string) error {
436- return runPasswordHash()
437- },
438- }
439-}
440-
441 // runTokenGenerate generates a new authentication token.409 // runTokenGenerate generates a new authentication token.
442 // parseAndConvertScopes parses a comma-separated scope string and converts410 // parseAndConvertScopes parses a comma-separated scope string and converts
443 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).411 // simple scopes (push, pull, *) to Docker v2 format (repository:*:action).
@@ -646,50 +614,6 @@ func runTokenRevokeAll(configPath string) error {
646 return nil614 return nil
647 }615 }
648 616
649-// runPasswordHash generates a bcrypt hash for a password.
650-func runPasswordHash() error {
651- fmt.Print("Enter password: ")
652-
653- // Read password without echo (use os.Stdin.Fd() for better compatibility)
654- fd, err := stdinFD()
655- if err != nil {
656- return fmt.Errorf("failed to get stdin fd: %w", err)
657- }
658- passwordBytes, err := term.ReadPassword(fd)
659- if err != nil {
660- // Fallback for non-terminal input
661- reader := bufio.NewReader(os.Stdin)
662- password, err := reader.ReadString('\n')
663- if err != nil {
664- return fmt.Errorf("failed to read password: %w", err)
665- }
666- passwordBytes = []byte(strings.TrimSpace(password))
667- }
668- fmt.Println()
669-
670- if len(passwordBytes) == 0 {
671- return fmt.Errorf("password cannot be empty")
672- }
673-
674- // Create a temporary auth service to generate the hash
675- authSvc := auth.NewService(auth.Config{}, nil, zerowrap.Default())
676- hash, err := authSvc.GeneratePasswordHash(string(passwordBytes))
677- if err != nil {
678- return fmt.Errorf("failed to generate hash: %w", err)
679- }
680-
681- fmt.Println()
682- fmt.Println("Bcrypt hash (store in your secrets backend):")
683- fmt.Println(hash)
684- fmt.Println()
685- fmt.Println("Then reference the path in your config:")
686- fmt.Println(" [auth]")
687- fmt.Println(" type = \"password\"")
688- fmt.Println(" password_hash = \"github.com/bnema/gordon/auth/password_hash\"")
689-
690- return nil
691-}
692-
693 // cliConfig holds the configuration needed for CLI commands.617 // cliConfig holds the configuration needed for CLI commands.
694 type cliConfig struct {618 type cliConfig struct {
695 Backend domain.SecretsBackend619 Backend domain.SecretsBackend
@@ -748,8 +672,7 @@ func loadAuthConfig(configPath string) (*cliConfig, error) {
748 cfg.TokenSecret = []byte(tokenSecretPath)672 cfg.TokenSecret = []byte(tokenSecretPath)
749 }673 }
750 } else {674 } else {
751- // Generate a default secret for CLI token generation675+ return nil, fmt.Errorf("token_secret is required for JWT token generation; set --token-secret flag or configure auth.token_secret")
752- cfg.TokenSecret = []byte("gordon-cli-default-secret")
753 }676 }
754 677
755 return cfg, nil678 return cfg, nil
M internal/adapters/in/http/admin/handler.go +12 -1
@@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
12641264 h.sendError(w, http.StatusBadRequest, "domain required in path")
12651265 return
12661266 }
1267+ if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1269+ return
1270+ }
12671271
12681272 // Get the route for this domain
12691273 route, err := h.configSvc.GetRoute(ctx, deployDomain)
@@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
13171321 h.sendError(w, http.StatusBadRequest, "domain required in path")
13181322 return
13191323 }
1320- if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {
1324+ if err := validation.ValidateDomainParam(restartDomain); err != nil {
13211325 h.sendError(w, http.StatusBadRequest, "invalid domain")
13221326 return
13231327 }
@@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
14331437 logDomain = ""
14341438 }
14351439
1440+ if logDomain != "" {
1441+ if err := validation.ValidateDomainParam(logDomain); err != nil {
1442+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1443+ return
1444+ }
1445+ }
1446+
14361447 if logDomain == "" {
14371448 // Gordon process logs
14381449 h.handleProcessLogs(w, r, lines, follow)
@@ -1264,6 +1264,10 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri
1264 h.sendError(w, http.StatusBadRequest, "domain required in path")1264 h.sendError(w, http.StatusBadRequest, "domain required in path")
1265 return1265 return
1266 }1266 }
1267+ if err := validation.ValidateDomainParam(deployDomain); err != nil {
1268+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1269+ return
1270+ }
1267 1271
1268 // Get the route for this domain1272 // Get the route for this domain
1269 route, err := h.configSvc.GetRoute(ctx, deployDomain)1273 route, err := h.configSvc.GetRoute(ctx, deployDomain)
@@ -1317,7 +1321,7 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
1317 h.sendError(w, http.StatusBadRequest, "domain required in path")1321 h.sendError(w, http.StatusBadRequest, "domain required in path")
1318 return1322 return
1319 }1323 }
1320- if strings.Contains(restartDomain, "..") || strings.ContainsAny(restartDomain, "\x00\n\r") {1324+ if err := validation.ValidateDomainParam(restartDomain); err != nil {
1321 h.sendError(w, http.StatusBadRequest, "invalid domain")1325 h.sendError(w, http.StatusBadRequest, "invalid domain")
1322 return1326 return
1323 }1327 }
@@ -1433,6 +1437,13 @@ func (h *Handler) handleLogs(w http.ResponseWriter, r *http.Request, path string
1433 logDomain = ""1437 logDomain = ""
1434 }1438 }
1435 1439
1440+ if logDomain != "" {
1441+ if err := validation.ValidateDomainParam(logDomain); err != nil {
1442+ h.sendError(w, http.StatusBadRequest, "invalid domain")
1443+ return
1444+ }
1445+ }
1446+
1436 if logDomain == "" {1447 if logDomain == "" {
1437 // Gordon process logs1448 // Gordon process logs
1438 h.handleProcessLogs(w, r, lines, follow)1449 h.handleProcessLogs(w, r, lines, follow)
M internal/adapters/in/http/admin/preview_handler.go +55 -37
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/zerowrap"
1212
1313 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
1415 )
1516
1617 // previewService is a minimal interface for preview management used by the admin handler.
@@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
7879 return
7980 }
8081
82+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
83+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
84+ return
85+ }
86+
8187 name := strings.TrimPrefix(path, "/preview/")
8288 if name == "" || name == "/preview" {
8389 h.sendError(w, http.StatusBadRequest, "preview name required in path")
8490 return
8591 }
92+ if err := validation.ValidateDomainParam(name); err != nil {
93+ h.sendError(w, http.StatusBadRequest, "invalid domain")
94+ return
95+ }
8696
8797 switch r.Method {
8898 case http.MethodDelete:
89- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91- return
92- }
9399
94100 if err := h.previewSvc.Delete(ctx, name); err != nil {
95101 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
@@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
105111 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106112
107113 case http.MethodPatch:
108- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
109- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110- return
111- }
114+ h.handlePreviewExtend(w, r, name)
112115
113- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
116+ default:
117+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
118+ }
119+}
114120
115- var req previewExtendRequest
116- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
117- log.Warn().Err(err).Msg("invalid preview extend JSON")
118- h.sendError(w, http.StatusBadRequest, "invalid JSON")
119- return
120- }
121+func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) {
122+ ctx := r.Context()
123+ log := zerowrap.FromCtx(ctx)
121124
122- if req.TTL == "" {
123- h.sendError(w, http.StatusBadRequest, "ttl is required")
124- return
125- }
125+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
126126
127- ttl, err := time.ParseDuration(req.TTL)
128- if err != nil {
129- h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
130- return
131- }
127+ var req previewExtendRequest
128+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
129+ log.Warn().Err(err).Msg("invalid preview extend JSON")
130+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
131+ return
132+ }
132133
133- if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
134- log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
135- if errors.Is(err, domain.ErrPreviewNotFound) {
136- h.sendError(w, http.StatusNotFound, "preview not found")
137- } else {
138- h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
139- }
140- return
141- }
134+ if req.TTL == "" {
135+ h.sendError(w, http.StatusBadRequest, "ttl is required")
136+ return
137+ }
138+
139+ ttl, err := time.ParseDuration(req.TTL)
140+ if err != nil {
141+ h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
142+ return
143+ }
142144
143- log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
144- h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
145+ if ttl <= 0 {
146+ h.sendError(w, http.StatusBadRequest, "TTL must be positive")
147+ return
148+ }
149+ const maxPreviewTTL = 30 * 24 * time.Hour // 30 days
150+ if ttl > maxPreviewTTL {
151+ h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days")
152+ return
153+ }
145154
146- default:
147- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
155+ if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
156+ log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
157+ if errors.Is(err, domain.ErrPreviewNotFound) {
158+ h.sendError(w, http.StatusNotFound, "preview not found")
159+ } else {
160+ h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
161+ }
162+ return
148163 }
164+
165+ log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
166+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
149167 }
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 12
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/pkg/validation"
14 )15 )
15 16
16 // previewService is a minimal interface for preview management used by the admin handler.17 // previewService is a minimal interface for preview management used by the admin handler.
@@ -78,18 +79,23 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
78 return79 return
79 }80 }
80 81
82+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
83+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
84+ return
85+ }
86+
81 name := strings.TrimPrefix(path, "/preview/")87 name := strings.TrimPrefix(path, "/preview/")
82 if name == "" || name == "/preview" {88 if name == "" || name == "/preview" {
83 h.sendError(w, http.StatusBadRequest, "preview name required in path")89 h.sendError(w, http.StatusBadRequest, "preview name required in path")
84 return90 return
85 }91 }
92+ if err := validation.ValidateDomainParam(name); err != nil {
93+ h.sendError(w, http.StatusBadRequest, "invalid domain")
94+ return
95+ }
86 96
87 switch r.Method {97 switch r.Method {
88 case http.MethodDelete:98 case http.MethodDelete:
89- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
90- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
91- return
92- }
93 99
94 if err := h.previewSvc.Delete(ctx, name); err != nil {100 if err := h.previewSvc.Delete(ctx, name); err != nil {
95 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")101 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
@@ -105,45 +111,57 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
105 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})111 h.sendJSON(w, http.StatusOK, map[string]string{"status": "deleted", "name": name})
106 112
107 case http.MethodPatch:113 case http.MethodPatch:
108- if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {114+ h.handlePreviewExtend(w, r, name)
109- h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
110- return
111- }
112 115
113- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)116+ default:
117+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
118+ }
119+}
114 120
115- var req previewExtendRequest121+func (h *Handler) handlePreviewExtend(w http.ResponseWriter, r *http.Request, name string) {
116- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {122+ ctx := r.Context()
117- log.Warn().Err(err).Msg("invalid preview extend JSON")123+ log := zerowrap.FromCtx(ctx)
118- h.sendError(w, http.StatusBadRequest, "invalid JSON")
119- return
120- }
121 124
122- if req.TTL == "" {125+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
123- h.sendError(w, http.StatusBadRequest, "ttl is required")
124- return
125- }
126 126
127- ttl, err := time.ParseDuration(req.TTL)127+ var req previewExtendRequest
128- if err != nil {128+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
129- h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")129+ log.Warn().Err(err).Msg("invalid preview extend JSON")
130- return130+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
131- }131+ return
132+ }
132 133
133- if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {134+ if req.TTL == "" {
134- log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")135+ h.sendError(w, http.StatusBadRequest, "ttl is required")
135- if errors.Is(err, domain.ErrPreviewNotFound) {136+ return
136- h.sendError(w, http.StatusNotFound, "preview not found")137+ }
137- } else {138+
138- h.sendError(w, http.StatusInternalServerError, "failed to extend preview")139+ ttl, err := time.ParseDuration(req.TTL)
139- }140+ if err != nil {
140- return141+ h.sendError(w, http.StatusBadRequest, "invalid ttl: use Go duration format, e.g. 24h or 30m")
141- }142+ return
143+ }
142 144
143- log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")145+ if ttl <= 0 {
144- h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})146+ h.sendError(w, http.StatusBadRequest, "TTL must be positive")
147+ return
148+ }
149+ const maxPreviewTTL = 30 * 24 * time.Hour // 30 days
150+ if ttl > maxPreviewTTL {
151+ h.sendError(w, http.StatusBadRequest, "TTL exceeds maximum of 30 days")
152+ return
153+ }
145 154
146- default:155+ if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
147- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")156+ log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
157+ if errors.Is(err, domain.ErrPreviewNotFound) {
158+ h.sendError(w, http.StatusNotFound, "preview not found")
159+ } else {
160+ h.sendError(w, http.StatusInternalServerError, "failed to extend preview")
161+ }
162+ return
148 }163 }
164+
165+ log.Info().Str("name", name).Dur("ttl", ttl).Msg("preview TTL extended via admin API")
166+ h.sendJSON(w, http.StatusOK, map[string]string{"status": "extended", "name": name, "ttl": req.TTL})
149 }167 }
M internal/adapters/in/http/auth/handler.go +42 -163
@@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.
3939 }
4040 }
4141
42-// PasswordRequest represents the request body for POST /auth/password.
43-type PasswordRequest struct {
44- Username string `json:"username"`
45- Password string `json:"password"`
46-}
47-
48-// PasswordResponse represents the response from POST /auth/password.
49-type PasswordResponse struct {
50- Token string `json:"token"`
51- ExpiresIn int `json:"expires_in"`
52- IssuedAt string `json:"issued_at"`
53-}
54-
55-const passwordSessionTTL = 24 * time.Hour
56-
5742 func setNoStoreHeaders(w http.ResponseWriter) {
5843 w.Header().Set("Cache-Control", "no-store")
5944 w.Header().Set("Pragma", "no-cache")
@@ -76,104 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
7661 }
7762
7863 // handlePassword handles POST /auth/password requests.
79-// Validates username/password and returns a daily session JWT.
80-// Only works when auth type is "password" - returns error for "token" auth type.
64+// Password authentication has been removed; this endpoint always returns Gone.
8165 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
8266 setNoStoreHeaders(w)
83-
84- ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85- zerowrap.FieldLayer: "adapter",
86- zerowrap.FieldAdapter: "http",
87- zerowrap.FieldHandler: "auth",
88- zerowrap.FieldMethod: r.Method,
89- zerowrap.FieldPath: r.URL.Path,
90- })
91- log := zerowrap.FromCtx(ctx)
92-
93- if r.Method != http.MethodPost {
94- w.Header().Set("Content-Type", "application/json")
95- w.WriteHeader(http.StatusMethodNotAllowed)
96- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
97- return
98- }
99-
100- // Check if auth is enabled
101- if h.authSvc == nil || !h.authSvc.IsEnabled() {
102- w.Header().Set("Content-Type", "application/json")
103- w.WriteHeader(http.StatusServiceUnavailable)
104- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
105- return
106- }
107-
108- // Password endpoint only works with password auth type
109- if h.authSvc.GetAuthType() != domain.AuthTypePassword {
110- w.Header().Set("Content-Type", "application/json")
111- w.WriteHeader(http.StatusBadRequest)
112- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
113- Error: "password authentication not configured, use token-based auth",
114- })
115- return
116- }
117-
118- // Parse request body
119- var req PasswordRequest
120- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
121- w.Header().Set("Content-Type", "application/json")
122- w.WriteHeader(http.StatusBadRequest)
123- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
124- return
125- }
126-
127- if req.Username == "" || req.Password == "" {
128- w.Header().Set("Content-Type", "application/json")
129- w.WriteHeader(http.StatusBadRequest)
130- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
131- return
132- }
133-
134- // Validate password
135- if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
136- log.Debug().
137- Str("username", req.Username).
138- Msg("password authentication failed")
139- w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
140- w.Header().Set("Content-Type", "application/json")
141- w.WriteHeader(http.StatusUnauthorized)
142- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
143- return
144- }
145-
146- // Generate daily session token with full access (push, pull, admin).
147- // This enforces regular re-authentication similar to short-session UX.
148- expiry := passwordSessionTTL
149- scopes := []string{"push", "pull", "admin:*:*"}
150-
151- token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
152- if err != nil {
153- log.Error().Err(err).Msg("failed to generate token")
154- w.Header().Set("Content-Type", "application/json")
155- w.WriteHeader(http.StatusInternalServerError)
156- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
157- return
158- }
159-
160- response := PasswordResponse{
161- Token: token,
162- ExpiresIn: int(expiry.Seconds()),
163- IssuedAt: time.Now().UTC().Format(time.RFC3339),
164- }
165-
16667 w.Header().Set("Content-Type", "application/json")
167- w.WriteHeader(http.StatusOK)
168-
169- if err := json.NewEncoder(w).Encode(response); err != nil {
170- log.Error().Err(err).Msg("failed to encode password response")
171- }
172-
173- log.Debug().
174- Str("username", req.Username).
175- Int("expires_in", response.ExpiresIn).
176- Msg("long-lived token issued via password auth")
68+ w.WriteHeader(http.StatusGone)
69+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
70+ Error: "password authentication has been removed, use token-based auth",
71+ })
17772 }
17873
17974 // handleToken handles GET /auth/token requests.
@@ -240,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
240135 }
241136 }
242137
243- // Generate a short-lived access token (5 minutes) - not stored
244- accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
138+ // Generate a short-lived access token with configurable TTL - not stored
139+ ttl := h.authSvc.GetAccessTokenTTL()
140+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
245141 if err != nil {
246142 log.Error().Err(err).Msg("failed to generate access token")
247143 w.Header().Set("Content-Type", "application/json")
@@ -252,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
252148
253149 response := dto.TokenResponse{
254150 Token: accessToken,
255- ExpiresIn: 300, // 5 minutes in seconds
151+ ExpiresIn: int(ttl.Seconds()),
256152 IssuedAt: time.Now().UTC().Format(time.RFC3339),
257153 }
258154
@@ -272,24 +168,18 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
272168 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
273169 if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
274170 log.Debug().Str("username", username).Msg("internal registry auth accepted")
275- return true, nil
171+ return true, &domain.TokenClaims{
172+ Subject: username,
173+ Scopes: []string{"push", "pull"},
174+ }
276175 }
277176
278- // Always try JWT token validation first — even in password-auth mode the
279- // server issues JWTs on login, and the CLI sends them back via Basic Auth
280- // for registry token exchange.
177+ // Validate JWT token sent via Basic Auth password field.
281178 claims, err := h.authSvc.ValidateToken(ctx, password)
282179 if err == nil && claims.Subject == username {
283180 return true, claims
284181 }
285182
286- // Fall back to password validation for direct username/password credentials.
287- if h.authSvc.GetAuthType() == domain.AuthTypePassword {
288- if h.authSvc.ValidatePassword(ctx, username, password) {
289- return true, nil
290- }
291- }
292-
293183 return false, nil
294184 }
295185
@@ -298,26 +188,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
298188
299189 for _, reqScopeStr := range requestedScopes {
300190 reqScope, err := domain.ParseScope(reqScopeStr)
301- if err != nil || reqScope.Type != domain.ScopeTypeRepository {
191+ if err != nil {
302192 continue
303193 }
304194
305- allowedActions := make([]string, 0, len(reqScope.Actions))
306- for _, action := range reqScope.Actions {
307- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
308- allowedActions = append(allowedActions, action)
309- }
310- }
311-
312- if len(allowedActions) == 0 {
195+ var checkAccess func([]string, string, string) bool
196+ switch reqScope.Type {
197+ case domain.ScopeTypeRepository:
198+ checkAccess = domain.ScopesGrantRegistryAccess
199+ case domain.ScopeTypeAdmin:
200+ checkAccess = domain.ScopesGrantAdminAccess
201+ default:
313202 continue
314203 }
315204
316- effective = append(effective, (&domain.Scope{
317- Type: reqScope.Type,
318- Name: reqScope.Name,
319- Actions: allowedActions,
320- }).String())
205+ if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" {
206+ effective = append(effective, s)
207+ }
321208 }
322209
323210 log.Debug().
@@ -329,31 +216,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
329216 return effective
330217 }
331218
332-func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {
333- for _, grantedScopeStr := range grantedScopes {
334- scopeStr := strings.TrimSpace(grantedScopeStr)
335- switch scopeStr {
336- case domain.ScopeActionAll:
337- return true
338- case domain.ScopeActionPull, domain.ScopeActionPush:
339- if scopeStr == action {
340- return true
341- }
342- }
343-
344- scope, err := domain.ParseScope(scopeStr)
345- if err != nil {
346- continue
347- }
348- if scope.Type != domain.ScopeTypeRepository {
349- continue
350- }
351- if scope.CanAccess(repoName, action) {
352- return true
219+// buildEffectiveScope filters a requested scope's actions through checkAccess and returns
220+// the resulting scope string, or "" if no actions were granted.
221+func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string {
222+ allowedActions := make([]string, 0, len(reqScope.Actions))
223+ for _, action := range reqScope.Actions {
224+ if checkAccess(grantedScopes, reqScope.Name, action) {
225+ allowedActions = append(allowedActions, action)
353226 }
354227 }
355-
356- return false
228+ if len(allowedActions) == 0 {
229+ return ""
230+ }
231+ return (&domain.Scope{
232+ Type: reqScope.Type,
233+ Name: reqScope.Name,
234+ Actions: allowedActions,
235+ }).String()
357236 }
358237
359238 // parseRequestedScopes extracts and validates scope parameters from the request.
@@ -380,8 +259,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s
380259 continue
381260 }
382261
383- // Only allow repository scopes
384- if scope.Type != "repository" {
262+ // Allow repository and admin scopes
263+ if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
385264 log.Debug().
386265 Str("scope_type", scope.Type).
387266 Str("scope", scopeStr).
@@ -39,21 +39,6 @@ func NewHandler(authSvc in.AuthService, internalAuth InternalAuth, log zerowrap.
39 }39 }
40 }40 }
41 41
42-// PasswordRequest represents the request body for POST /auth/password.
43-type PasswordRequest struct {
44- Username string `json:"username"`
45- Password string `json:"password"`
46-}
47-
48-// PasswordResponse represents the response from POST /auth/password.
49-type PasswordResponse struct {
50- Token string `json:"token"`
51- ExpiresIn int `json:"expires_in"`
52- IssuedAt string `json:"issued_at"`
53-}
54-
55-const passwordSessionTTL = 24 * time.Hour
56-
57 func setNoStoreHeaders(w http.ResponseWriter) {42 func setNoStoreHeaders(w http.ResponseWriter) {
58 w.Header().Set("Cache-Control", "no-store")43 w.Header().Set("Cache-Control", "no-store")
59 w.Header().Set("Pragma", "no-cache")44 w.Header().Set("Pragma", "no-cache")
@@ -76,104 +61,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
76 }61 }
77 62
78 // handlePassword handles POST /auth/password requests.63 // handlePassword handles POST /auth/password requests.
79-// Validates username/password and returns a daily session JWT.64+// Password authentication has been removed; this endpoint always returns Gone.
80-// Only works when auth type is "password" - returns error for "token" auth type.
81 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {65 func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) {
82 setNoStoreHeaders(w)66 setNoStoreHeaders(w)
83-
84- ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85- zerowrap.FieldLayer: "adapter",
86- zerowrap.FieldAdapter: "http",
87- zerowrap.FieldHandler: "auth",
88- zerowrap.FieldMethod: r.Method,
89- zerowrap.FieldPath: r.URL.Path,
90- })
91- log := zerowrap.FromCtx(ctx)
92-
93- if r.Method != http.MethodPost {
94- w.Header().Set("Content-Type", "application/json")
95- w.WriteHeader(http.StatusMethodNotAllowed)
96- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "method not allowed"})
97- return
98- }
99-
100- // Check if auth is enabled
101- if h.authSvc == nil || !h.authSvc.IsEnabled() {
102- w.Header().Set("Content-Type", "application/json")
103- w.WriteHeader(http.StatusServiceUnavailable)
104- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "authentication is required"})
105- return
106- }
107-
108- // Password endpoint only works with password auth type
109- if h.authSvc.GetAuthType() != domain.AuthTypePassword {
110- w.Header().Set("Content-Type", "application/json")
111- w.WriteHeader(http.StatusBadRequest)
112- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
113- Error: "password authentication not configured, use token-based auth",
114- })
115- return
116- }
117-
118- // Parse request body
119- var req PasswordRequest
120- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
121- w.Header().Set("Content-Type", "application/json")
122- w.WriteHeader(http.StatusBadRequest)
123- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid request body"})
124- return
125- }
126-
127- if req.Username == "" || req.Password == "" {
128- w.Header().Set("Content-Type", "application/json")
129- w.WriteHeader(http.StatusBadRequest)
130- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "username and password are required"})
131- return
132- }
133-
134- // Validate password
135- if !h.authSvc.ValidatePassword(ctx, req.Username, req.Password) {
136- log.Debug().
137- Str("username", req.Username).
138- Msg("password authentication failed")
139- w.Header().Set("WWW-Authenticate", `Basic realm="Gordon"`)
140- w.Header().Set("Content-Type", "application/json")
141- w.WriteHeader(http.StatusUnauthorized)
142- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "invalid credentials"})
143- return
144- }
145-
146- // Generate daily session token with full access (push, pull, admin).
147- // This enforces regular re-authentication similar to short-session UX.
148- expiry := passwordSessionTTL
149- scopes := []string{"push", "pull", "admin:*:*"}
150-
151- token, err := h.authSvc.GenerateToken(ctx, req.Username, scopes, expiry)
152- if err != nil {
153- log.Error().Err(err).Msg("failed to generate token")
154- w.Header().Set("Content-Type", "application/json")
155- w.WriteHeader(http.StatusInternalServerError)
156- _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "failed to generate token"})
157- return
158- }
159-
160- response := PasswordResponse{
161- Token: token,
162- ExpiresIn: int(expiry.Seconds()),
163- IssuedAt: time.Now().UTC().Format(time.RFC3339),
164- }
165-
166 w.Header().Set("Content-Type", "application/json")67 w.Header().Set("Content-Type", "application/json")
167- w.WriteHeader(http.StatusOK)68+ w.WriteHeader(http.StatusGone)
168-69+ _ = json.NewEncoder(w).Encode(dto.ErrorResponse{
169- if err := json.NewEncoder(w).Encode(response); err != nil {70+ Error: "password authentication has been removed, use token-based auth",
170- log.Error().Err(err).Msg("failed to encode password response")71+ })
171- }
172-
173- log.Debug().
174- Str("username", req.Username).
175- Int("expires_in", response.ExpiresIn).
176- Msg("long-lived token issued via password auth")
177 }72 }
178 73
179 // handleToken handles GET /auth/token requests.74 // handleToken handles GET /auth/token requests.
@@ -240,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
240 }135 }
241 }136 }
242 137
243- // Generate a short-lived access token (5 minutes) - not stored138+ // Generate a short-lived access token with configurable TTL - not stored
244- accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)139+ ttl := h.authSvc.GetAccessTokenTTL()
140+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
245 if err != nil {141 if err != nil {
246 log.Error().Err(err).Msg("failed to generate access token")142 log.Error().Err(err).Msg("failed to generate access token")
247 w.Header().Set("Content-Type", "application/json")143 w.Header().Set("Content-Type", "application/json")
@@ -252,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
252 148
253 response := dto.TokenResponse{149 response := dto.TokenResponse{
254 Token: accessToken,150 Token: accessToken,
255- ExpiresIn: 300, // 5 minutes in seconds151+ ExpiresIn: int(ttl.Seconds()),
256 IssuedAt: time.Now().UTC().Format(time.RFC3339),152 IssuedAt: time.Now().UTC().Format(time.RFC3339),
257 }153 }
258 154
@@ -272,24 +168,18 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
272 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {168 func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) {
273 if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {169 if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) {
274 log.Debug().Str("username", username).Msg("internal registry auth accepted")170 log.Debug().Str("username", username).Msg("internal registry auth accepted")
275- return true, nil171+ return true, &domain.TokenClaims{
172+ Subject: username,
173+ Scopes: []string{"push", "pull"},
174+ }
276 }175 }
277 176
278- // Always try JWT token validation first — even in password-auth mode the177+ // Validate JWT token sent via Basic Auth password field.
279- // server issues JWTs on login, and the CLI sends them back via Basic Auth
280- // for registry token exchange.
281 claims, err := h.authSvc.ValidateToken(ctx, password)178 claims, err := h.authSvc.ValidateToken(ctx, password)
282 if err == nil && claims.Subject == username {179 if err == nil && claims.Subject == username {
283 return true, claims180 return true, claims
284 }181 }
285 182
286- // Fall back to password validation for direct username/password credentials.
287- if h.authSvc.GetAuthType() == domain.AuthTypePassword {
288- if h.authSvc.ValidatePassword(ctx, username, password) {
289- return true, nil
290- }
291- }
292-
293 return false, nil183 return false, nil
294 }184 }
295 185
@@ -298,26 +188,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
298 188
299 for _, reqScopeStr := range requestedScopes {189 for _, reqScopeStr := range requestedScopes {
300 reqScope, err := domain.ParseScope(reqScopeStr)190 reqScope, err := domain.ParseScope(reqScopeStr)
301- if err != nil || reqScope.Type != domain.ScopeTypeRepository {191+ if err != nil {
302 continue192 continue
303 }193 }
304 194
305- allowedActions := make([]string, 0, len(reqScope.Actions))195+ var checkAccess func([]string, string, string) bool
306- for _, action := range reqScope.Actions {196+ switch reqScope.Type {
307- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {197+ case domain.ScopeTypeRepository:
308- allowedActions = append(allowedActions, action)198+ checkAccess = domain.ScopesGrantRegistryAccess
309- }199+ case domain.ScopeTypeAdmin:
310- }200+ checkAccess = domain.ScopesGrantAdminAccess
311-201+ default:
312- if len(allowedActions) == 0 {
313 continue202 continue
314 }203 }
315 204
316- effective = append(effective, (&domain.Scope{205+ if s := buildEffectiveScope(reqScope, grantedScopes, checkAccess); s != "" {
317- Type: reqScope.Type,206+ effective = append(effective, s)
318- Name: reqScope.Name,207+ }
319- Actions: allowedActions,
320- }).String())
321 }208 }
322 209
323 log.Debug().210 log.Debug().
@@ -329,31 +216,23 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
329 return effective216 return effective
330 }217 }
331 218
332-func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) bool {219+// buildEffectiveScope filters a requested scope's actions through checkAccess and returns
333- for _, grantedScopeStr := range grantedScopes {220+// the resulting scope string, or "" if no actions were granted.
334- scopeStr := strings.TrimSpace(grantedScopeStr)221+func buildEffectiveScope(reqScope *domain.Scope, grantedScopes []string, checkAccess func([]string, string, string) bool) string {
335- switch scopeStr {222+ allowedActions := make([]string, 0, len(reqScope.Actions))
336- case domain.ScopeActionAll:223+ for _, action := range reqScope.Actions {
337- return true224+ if checkAccess(grantedScopes, reqScope.Name, action) {
338- case domain.ScopeActionPull, domain.ScopeActionPush:225+ allowedActions = append(allowedActions, action)
339- if scopeStr == action {
340- return true
341- }
342- }
343-
344- scope, err := domain.ParseScope(scopeStr)
345- if err != nil {
346- continue
347- }
348- if scope.Type != domain.ScopeTypeRepository {
349- continue
350- }
351- if scope.CanAccess(repoName, action) {
352- return true
353 }226 }
354 }227 }
355-228+ if len(allowedActions) == 0 {
356- return false229+ return ""
230+ }
231+ return (&domain.Scope{
232+ Type: reqScope.Type,
233+ Name: reqScope.Name,
234+ Actions: allowedActions,
235+ }).String()
357 }236 }
358 237
359 // parseRequestedScopes extracts and validates scope parameters from the request.238 // parseRequestedScopes extracts and validates scope parameters from the request.
@@ -380,8 +259,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s
380 continue259 continue
381 }260 }
382 261
383- // Only allow repository scopes262+ // Allow repository and admin scopes
384- if scope.Type != "repository" {263+ if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
385 log.Debug().264 log.Debug().
386 Str("scope_type", scope.Type).265 Str("scope_type", scope.Type).
387 Str("scope", scopeStr).266 Str("scope", scopeStr).
M internal/adapters/in/http/auth/handler_test.go +99 -138
@@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger {
2222 return zerowrap.Default()
2323 }
2424
25-func TestHandler_Password_Success(t *testing.T) {
26- authSvc := mocks.NewMockAuthService(t)
27-
28- authSvc.EXPECT().IsEnabled().Return(true)
29- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
30- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
31- authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour).
32- Return("jwt-token-here", nil)
33-
34- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
35-
36- body := `{"username":"admin","password":"secret"}`
37- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
38- req.Header.Set("Content-Type", "application/json")
39- rec := httptest.NewRecorder()
40-
41- handler.ServeHTTP(rec, req)
42-
43- assert.Equal(t, http.StatusOK, rec.Code)
44-
45- var resp PasswordResponse
46- err := json.NewDecoder(rec.Body).Decode(&resp)
47- assert.NoError(t, err)
48- assert.Equal(t, "jwt-token-here", resp.Token)
49- assert.Equal(t, 24*60*60, resp.ExpiresIn)
50- assert.NotEmpty(t, resp.IssuedAt)
51-}
52-
53-func TestHandler_Password_InvalidCredentials(t *testing.T) {
54- authSvc := mocks.NewMockAuthService(t)
55-
56- authSvc.EXPECT().IsEnabled().Return(true)
57- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
58- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
59-
60- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
61-
62- body := `{"username":"admin","password":"wrongpassword"}`
63- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
64- req.Header.Set("Content-Type", "application/json")
65- rec := httptest.NewRecorder()
66-
67- handler.ServeHTTP(rec, req)
68-
69- assert.Equal(t, http.StatusUnauthorized, rec.Code)
70- assert.Contains(t, rec.Body.String(), "invalid credentials")
71-}
72-
73-func TestHandler_Password_AuthTypeToken(t *testing.T) {
74- authSvc := mocks.NewMockAuthService(t)
75-
76- authSvc.EXPECT().IsEnabled().Return(true)
77- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
78-
79- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
80-
81- body := `{"username":"admin","password":"secret"}`
82- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
83- req.Header.Set("Content-Type", "application/json")
84- rec := httptest.NewRecorder()
85-
86- handler.ServeHTTP(rec, req)
87-
88- assert.Equal(t, http.StatusBadRequest, rec.Code)
89- assert.Contains(t, rec.Body.String(), "password authentication not configured")
90-}
91-
92-func TestHandler_Password_AuthDisabled(t *testing.T) {
93- authSvc := mocks.NewMockAuthService(t)
94-
95- authSvc.EXPECT().IsEnabled().Return(false)
96-
97- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
25+func TestHandler_Password_AlwaysGone(t *testing.T) {
26+ handler := NewHandler(nil, InternalAuth{}, testLogger())
9827
9928 body := `{"username":"admin","password":"secret"}`
10029 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
@@ -103,73 +32,26 @@ func TestHandler_Password_AuthDisabled(t *testing.T) {
10332
10433 handler.ServeHTTP(rec, req)
10534
106- assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
107- assert.Contains(t, rec.Body.String(), "authentication is required")
108-}
109-
110-func TestHandler_Password_MissingFields(t *testing.T) {
111- authSvc := mocks.NewMockAuthService(t)
112-
113- authSvc.EXPECT().IsEnabled().Return(true)
114- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
115-
116- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
117-
118- tests := []struct {
119- name string
120- body string
121- }{
122- {"missing username", `{"password":"secret"}`},
123- {"missing password", `{"username":"admin"}`},
124- {"empty username", `{"username":"","password":"secret"}`},
125- {"empty password", `{"username":"admin","password":""}`},
126- }
127-
128- for _, tt := range tests {
129- t.Run(tt.name, func(t *testing.T) {
130- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
131- req.Header.Set("Content-Type", "application/json")
132- rec := httptest.NewRecorder()
133-
134- handler.ServeHTTP(rec, req)
135-
136- assert.Equal(t, http.StatusBadRequest, rec.Code)
137- })
138- }
139-}
140-
141-func TestHandler_Password_MethodNotAllowed(t *testing.T) {
142- handler := NewHandler(nil, InternalAuth{}, testLogger())
143-
144- methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
145-
146- for _, method := range methods {
147- t.Run(method, func(t *testing.T) {
148- req := httptest.NewRequest(method, "/auth/password", nil)
149- rec := httptest.NewRecorder()
150-
151- handler.ServeHTTP(rec, req)
152-
153- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
154- })
155- }
35+ assert.Equal(t, http.StatusGone, rec.Code)
36+ assert.Contains(t, rec.Body.String(), "password authentication has been removed")
15637 }
15738
15839 func TestHandler_Token_Success(t *testing.T) {
15940 authSvc := mocks.NewMockAuthService(t)
16041
16142 authSvc.EXPECT().IsEnabled().Return(true)
162- // ValidateToken is tried first; plain password fails JWT parsing
163- authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token"))
164- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
165- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
166- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
43+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
44+ Subject: "admin",
45+ Scopes: []string{"repository:myrepo:pull"},
46+ }, nil)
47+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
16749 Return("access-token", nil)
16850
16951 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
17052
17153 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
172- req.SetBasicAuth("admin", "secret")
54+ req.SetBasicAuth("admin", "existing-jwt-token")
17355 rec := httptest.NewRecorder()
17456
17557 handler.ServeHTTP(rec, req)
@@ -180,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) {
18062 err := json.NewDecoder(rec.Body).Decode(&resp)
18163 assert.NoError(t, err)
18264 assert.Equal(t, "access-token", resp["token"])
183- assert.Equal(t, float64(300), resp["expires_in"])
65+ assert.Equal(t, float64(900), resp["expires_in"])
18466 }
18567
18668 func TestHandler_Token_MissingCredentials(t *testing.T) {
@@ -218,14 +100,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) {
218100 authSvc := mocks.NewMockAuthService(t)
219101
220102 authSvc.EXPECT().IsEnabled().Return(true)
221- authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token"))
222- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
223- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
103+ authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token"))
224104
225105 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
226106
227107 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
228- req.SetBasicAuth("admin", "wrongpassword")
108+ req.SetBasicAuth("admin", "invalid-token")
229109 rec := httptest.NewRecorder()
230110
231111 handler.ServeHTTP(rec, req)
@@ -237,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) {
237117 authSvc := mocks.NewMockAuthService(t)
238118
239119 authSvc.EXPECT().IsEnabled().Return(true)
240- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
120+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
241122 Return("internal-access-token", nil)
242123
243124 internalAuth := InternalAuth{
@@ -269,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
269150 Subject: "myuser",
270151 Scopes: []string{"repository:*:pull"},
271152 }, nil)
272- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
153+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
273155 Return("short-lived-token", nil)
274156
275157 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -296,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
296178 Subject: "myuser",
297179 Scopes: []string{"repository:myrepo:pull"},
298180 }, nil)
299- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).
181+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
300183 Return("scoped-token", nil)
301184
302185 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -344,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
344227 Subject: "brissou",
345228 Scopes: []string{"*"},
346229 }, nil)
347- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute).
230+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
348232 Return("short-lived-token", nil)
349233
350234 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -563,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) {
563447 })
564448 }
565449 }
450+
451+func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "admin",
457+ Scopes: []string{"admin:routes:read,write"},
458+ }, nil)
459+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
460+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
461+ Return("admin-access-token", nil)
462+
463+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
464+
465+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
466+ req.SetBasicAuth("admin", "admin-jwt-token")
467+ rec := httptest.NewRecorder()
468+
469+ handler.ServeHTTP(rec, req)
470+
471+ assert.Equal(t, http.StatusOK, rec.Code)
472+
473+ var resp map[string]any
474+ err := json.NewDecoder(rec.Body).Decode(&resp)
475+ assert.NoError(t, err)
476+ assert.Equal(t, "admin-access-token", resp["token"])
477+}
478+
479+func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
480+ authSvc := mocks.NewMockAuthService(t)
481+
482+ authSvc.EXPECT().IsEnabled().Return(true)
483+ authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
484+ Subject: "myuser",
485+ Scopes: []string{"repository:myrepo:pull,push"},
486+ }, nil)
487+
488+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
489+
490+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
491+ req.SetBasicAuth("myuser", "registry-jwt-token")
492+ rec := httptest.NewRecorder()
493+
494+ handler.ServeHTTP(rec, req)
495+
496+ assert.Equal(t, http.StatusForbidden, rec.Code)
497+ assert.Contains(t, rec.Body.String(), "insufficient scope")
498+}
499+
500+func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
501+ authSvc := mocks.NewMockAuthService(t)
502+
503+ authSvc.EXPECT().IsEnabled().Return(true)
504+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
505+ Subject: "superadmin",
506+ Scopes: []string{"admin:*:*"},
507+ }, nil)
508+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
509+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
510+ Return("wildcard-access-token", nil)
511+
512+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
513+
514+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
515+ req.SetBasicAuth("superadmin", "admin-wildcard-token")
516+ rec := httptest.NewRecorder()
517+
518+ handler.ServeHTTP(rec, req)
519+
520+ assert.Equal(t, http.StatusOK, rec.Code)
521+
522+ var resp map[string]any
523+ err := json.NewDecoder(rec.Body).Decode(&resp)
524+ assert.NoError(t, err)
525+ assert.Equal(t, "wildcard-access-token", resp["token"])
526+}
@@ -22,79 +22,8 @@ func testLogger() zerowrap.Logger {
22 return zerowrap.Default()22 return zerowrap.Default()
23 }23 }
24 24
25-func TestHandler_Password_Success(t *testing.T) {25+func TestHandler_Password_AlwaysGone(t *testing.T) {
26- authSvc := mocks.NewMockAuthService(t)26+ handler := NewHandler(nil, InternalAuth{}, testLogger())
27-
28- authSvc.EXPECT().IsEnabled().Return(true)
29- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
30- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
31- authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 24*time.Hour).
32- Return("jwt-token-here", nil)
33-
34- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
35-
36- body := `{"username":"admin","password":"secret"}`
37- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
38- req.Header.Set("Content-Type", "application/json")
39- rec := httptest.NewRecorder()
40-
41- handler.ServeHTTP(rec, req)
42-
43- assert.Equal(t, http.StatusOK, rec.Code)
44-
45- var resp PasswordResponse
46- err := json.NewDecoder(rec.Body).Decode(&resp)
47- assert.NoError(t, err)
48- assert.Equal(t, "jwt-token-here", resp.Token)
49- assert.Equal(t, 24*60*60, resp.ExpiresIn)
50- assert.NotEmpty(t, resp.IssuedAt)
51-}
52-
53-func TestHandler_Password_InvalidCredentials(t *testing.T) {
54- authSvc := mocks.NewMockAuthService(t)
55-
56- authSvc.EXPECT().IsEnabled().Return(true)
57- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
58- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
59-
60- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
61-
62- body := `{"username":"admin","password":"wrongpassword"}`
63- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
64- req.Header.Set("Content-Type", "application/json")
65- rec := httptest.NewRecorder()
66-
67- handler.ServeHTTP(rec, req)
68-
69- assert.Equal(t, http.StatusUnauthorized, rec.Code)
70- assert.Contains(t, rec.Body.String(), "invalid credentials")
71-}
72-
73-func TestHandler_Password_AuthTypeToken(t *testing.T) {
74- authSvc := mocks.NewMockAuthService(t)
75-
76- authSvc.EXPECT().IsEnabled().Return(true)
77- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
78-
79- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
80-
81- body := `{"username":"admin","password":"secret"}`
82- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
83- req.Header.Set("Content-Type", "application/json")
84- rec := httptest.NewRecorder()
85-
86- handler.ServeHTTP(rec, req)
87-
88- assert.Equal(t, http.StatusBadRequest, rec.Code)
89- assert.Contains(t, rec.Body.String(), "password authentication not configured")
90-}
91-
92-func TestHandler_Password_AuthDisabled(t *testing.T) {
93- authSvc := mocks.NewMockAuthService(t)
94-
95- authSvc.EXPECT().IsEnabled().Return(false)
96-
97- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
98 27
99 body := `{"username":"admin","password":"secret"}`28 body := `{"username":"admin","password":"secret"}`
100 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))29 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
@@ -103,73 +32,26 @@ func TestHandler_Password_AuthDisabled(t *testing.T) {
103 32
104 handler.ServeHTTP(rec, req)33 handler.ServeHTTP(rec, req)
105 34
106- assert.Equal(t, http.StatusServiceUnavailable, rec.Code)35+ assert.Equal(t, http.StatusGone, rec.Code)
107- assert.Contains(t, rec.Body.String(), "authentication is required")36+ assert.Contains(t, rec.Body.String(), "password authentication has been removed")
108-}
109-
110-func TestHandler_Password_MissingFields(t *testing.T) {
111- authSvc := mocks.NewMockAuthService(t)
112-
113- authSvc.EXPECT().IsEnabled().Return(true)
114- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
115-
116- handler := NewHandler(authSvc, InternalAuth{}, testLogger())
117-
118- tests := []struct {
119- name string
120- body string
121- }{
122- {"missing username", `{"password":"secret"}`},
123- {"missing password", `{"username":"admin"}`},
124- {"empty username", `{"username":"","password":"secret"}`},
125- {"empty password", `{"username":"admin","password":""}`},
126- }
127-
128- for _, tt := range tests {
129- t.Run(tt.name, func(t *testing.T) {
130- req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
131- req.Header.Set("Content-Type", "application/json")
132- rec := httptest.NewRecorder()
133-
134- handler.ServeHTTP(rec, req)
135-
136- assert.Equal(t, http.StatusBadRequest, rec.Code)
137- })
138- }
139-}
140-
141-func TestHandler_Password_MethodNotAllowed(t *testing.T) {
142- handler := NewHandler(nil, InternalAuth{}, testLogger())
143-
144- methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
145-
146- for _, method := range methods {
147- t.Run(method, func(t *testing.T) {
148- req := httptest.NewRequest(method, "/auth/password", nil)
149- rec := httptest.NewRecorder()
150-
151- handler.ServeHTTP(rec, req)
152-
153- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
154- })
155- }
156 }37 }
157 38
158 func TestHandler_Token_Success(t *testing.T) {39 func TestHandler_Token_Success(t *testing.T) {
159 authSvc := mocks.NewMockAuthService(t)40 authSvc := mocks.NewMockAuthService(t)
160 41
161 authSvc.EXPECT().IsEnabled().Return(true)42 authSvc.EXPECT().IsEnabled().Return(true)
162- // ValidateToken is tried first; plain password fails JWT parsing43+ authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
163- authSvc.EXPECT().ValidateToken(mock.Anything, "secret").Return(nil, fmt.Errorf("not a token"))44+ Subject: "admin",
164- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)45+ Scopes: []string{"repository:myrepo:pull"},
165- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)46+ }, nil)
166- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).47+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
167 Return("access-token", nil)49 Return("access-token", nil)
168 50
169 handler := NewHandler(authSvc, InternalAuth{}, testLogger())51 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
170 52
171 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)53 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
172- req.SetBasicAuth("admin", "secret")54+ req.SetBasicAuth("admin", "existing-jwt-token")
173 rec := httptest.NewRecorder()55 rec := httptest.NewRecorder()
174 56
175 handler.ServeHTTP(rec, req)57 handler.ServeHTTP(rec, req)
@@ -180,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) {
180 err := json.NewDecoder(rec.Body).Decode(&resp)62 err := json.NewDecoder(rec.Body).Decode(&resp)
181 assert.NoError(t, err)63 assert.NoError(t, err)
182 assert.Equal(t, "access-token", resp["token"])64 assert.Equal(t, "access-token", resp["token"])
183- assert.Equal(t, float64(300), resp["expires_in"])65+ assert.Equal(t, float64(900), resp["expires_in"])
184 }66 }
185 67
186 func TestHandler_Token_MissingCredentials(t *testing.T) {68 func TestHandler_Token_MissingCredentials(t *testing.T) {
@@ -218,14 +100,12 @@ func TestHandler_Token_InvalidCredentials(t *testing.T) {
218 authSvc := mocks.NewMockAuthService(t)100 authSvc := mocks.NewMockAuthService(t)
219 101
220 authSvc.EXPECT().IsEnabled().Return(true)102 authSvc.EXPECT().IsEnabled().Return(true)
221- authSvc.EXPECT().ValidateToken(mock.Anything, "wrongpassword").Return(nil, fmt.Errorf("not a token"))103+ authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token"))
222- authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
223- authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
224 104
225 handler := NewHandler(authSvc, InternalAuth{}, testLogger())105 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
226 106
227 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)107 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
228- req.SetBasicAuth("admin", "wrongpassword")108+ req.SetBasicAuth("admin", "invalid-token")
229 rec := httptest.NewRecorder()109 rec := httptest.NewRecorder()
230 110
231 handler.ServeHTTP(rec, req)111 handler.ServeHTTP(rec, req)
@@ -237,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) {
237 authSvc := mocks.NewMockAuthService(t)117 authSvc := mocks.NewMockAuthService(t)
238 118
239 authSvc.EXPECT().IsEnabled().Return(true)119 authSvc.EXPECT().IsEnabled().Return(true)
240- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).120+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
241 Return("internal-access-token", nil)122 Return("internal-access-token", nil)
242 123
243 internalAuth := InternalAuth{124 internalAuth := InternalAuth{
@@ -269,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
269 Subject: "myuser",150 Subject: "myuser",
270 Scopes: []string{"repository:*:pull"},151 Scopes: []string{"repository:*:pull"},
271 }, nil)152 }, nil)
272- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).153+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
273 Return("short-lived-token", nil)155 Return("short-lived-token", nil)
274 156
275 handler := NewHandler(authSvc, InternalAuth{}, testLogger())157 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -296,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
296 Subject: "myuser",178 Subject: "myuser",
297 Scopes: []string{"repository:myrepo:pull"},179 Scopes: []string{"repository:myrepo:pull"},
298 }, nil)180 }, nil)
299- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).181+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
300 Return("scoped-token", nil)183 Return("scoped-token", nil)
301 184
302 handler := NewHandler(authSvc, InternalAuth{}, testLogger())185 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -344,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
344 Subject: "brissou",227 Subject: "brissou",
345 Scopes: []string{"*"},228 Scopes: []string{"*"},
346 }, nil)229 }, nil)
347- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute).230+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
348 Return("short-lived-token", nil)232 Return("short-lived-token", nil)
349 233
350 handler := NewHandler(authSvc, InternalAuth{}, testLogger())234 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -563,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) {
563 })447 })
564 }448 }
565 }449 }
450+
451+func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "admin",
457+ Scopes: []string{"admin:routes:read,write"},
458+ }, nil)
459+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
460+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
461+ Return("admin-access-token", nil)
462+
463+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
464+
465+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
466+ req.SetBasicAuth("admin", "admin-jwt-token")
467+ rec := httptest.NewRecorder()
468+
469+ handler.ServeHTTP(rec, req)
470+
471+ assert.Equal(t, http.StatusOK, rec.Code)
472+
473+ var resp map[string]any
474+ err := json.NewDecoder(rec.Body).Decode(&resp)
475+ assert.NoError(t, err)
476+ assert.Equal(t, "admin-access-token", resp["token"])
477+}
478+
479+func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
480+ authSvc := mocks.NewMockAuthService(t)
481+
482+ authSvc.EXPECT().IsEnabled().Return(true)
483+ authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
484+ Subject: "myuser",
485+ Scopes: []string{"repository:myrepo:pull,push"},
486+ }, nil)
487+
488+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
489+
490+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
491+ req.SetBasicAuth("myuser", "registry-jwt-token")
492+ rec := httptest.NewRecorder()
493+
494+ handler.ServeHTTP(rec, req)
495+
496+ assert.Equal(t, http.StatusForbidden, rec.Code)
497+ assert.Contains(t, rec.Body.String(), "insufficient scope")
498+}
499+
500+func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
501+ authSvc := mocks.NewMockAuthService(t)
502+
503+ authSvc.EXPECT().IsEnabled().Return(true)
504+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
505+ Subject: "superadmin",
506+ Scopes: []string{"admin:*:*"},
507+ }, nil)
508+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
509+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
510+ Return("wildcard-access-token", nil)
511+
512+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
513+
514+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
515+ req.SetBasicAuth("superadmin", "admin-wildcard-token")
516+ rec := httptest.NewRecorder()
517+
518+ handler.ServeHTTP(rec, req)
519+
520+ assert.Equal(t, http.StatusOK, rec.Code)
521+
522+ var resp map[string]any
523+ err := json.NewDecoder(rec.Body).Decode(&resp)
524+ assert.NoError(t, err)
525+ assert.Equal(t, "wildcard-access-token", resp["token"])
526+}
M internal/adapters/in/http/middleware/auth.go +9 -70
@@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
9393 // (allows CI/CD tokens while still supporting interactive password login)
9494 var authenticated bool
9595 var tokenClaims *domain.TokenClaims
96- switch authSvc.GetAuthType() {
97- case domain.AuthTypePassword:
98- // Try password auth first
99- authenticated = authenticatePassword(ctx, r, authSvc, log)
100- if !authenticated {
101- // Fall back to token auth (for CI/CD tokens)
102- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
103- }
104- case domain.AuthTypeToken:
105- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
106- default:
107- log.Error().
108- Str("auth_type", string(authSvc.GetAuthType())).
109- Msg("unknown auth type")
110- authenticated = false
111- }
96+ authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
11297
11398 if !authenticated {
11499 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
@@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
132117 }
133118 }
134119
135-// authenticatePassword handles password-based authentication.
136-func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
137- username, password, ok := r.BasicAuth()
138- if !ok {
139- log.Debug().
140- Str(zerowrap.FieldMethod, r.Method).
141- Str(zerowrap.FieldPath, r.URL.Path).
142- Msg("no basic auth provided")
143- return false
144- }
145-
146- if authSvc.ValidatePassword(ctx, username, password) {
147- log.Debug().
148- Str("username", username).
149- Str(zerowrap.FieldMethod, r.Method).
150- Str(zerowrap.FieldPath, r.URL.Path).
151- Msg("password authentication successful")
152- return true
153- }
154-
155- log.Debug().
156- Str("provided_username", redactUsername(username)).
157- Str(zerowrap.FieldMethod, r.Method).
158- Str(zerowrap.FieldPath, r.URL.Path).
159- Msg("password authentication failed")
160- return false
161-}
162-
163120 // authenticateToken handles token-based authentication.
164121 // It supports both Bearer token in Authorization header and token-as-password for CI.
165122 // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.
@@ -292,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
292249 return true
293250 }
294251
295- // Check if any token scope grants access
296- for _, scopeStr := range claims.Scopes {
297- // Handle simple scopes (e.g., "push", "pull") for backwards compatibility
298- if scopeStr == action || scopeStr == "*" {
299- log.Debug().
300- Str("repo", repoName).
301- Str("action", action).
302- Str("scope", scopeStr).
303- Msg("simple scope access granted")
304- return true
305- }
306-
307- // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
308- scope, err := domain.ParseScope(scopeStr)
309- if err != nil {
310- continue
311- }
312-
313- if scope.CanAccess(repoName, action) {
314- log.Debug().
315- Str("repo", repoName).
316- Str("action", action).
317- Str("scope", scopeStr).
318- Msg("scope access granted")
319- return true
320- }
252+ // Delegate matching to domain layer
253+ if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) {
254+ log.Debug().
255+ Str("repo", repoName).
256+ Str("action", action).
257+ Strs("scopes", claims.Scopes).
258+ Msg("scope access granted")
259+ return true
321260 }
322261
323262 log.Debug().
@@ -93,22 +93,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
93 // (allows CI/CD tokens while still supporting interactive password login)93 // (allows CI/CD tokens while still supporting interactive password login)
94 var authenticated bool94 var authenticated bool
95 var tokenClaims *domain.TokenClaims95 var tokenClaims *domain.TokenClaims
96- switch authSvc.GetAuthType() {96+ authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
97- case domain.AuthTypePassword:
98- // Try password auth first
99- authenticated = authenticatePassword(ctx, r, authSvc, log)
100- if !authenticated {
101- // Fall back to token auth (for CI/CD tokens)
102- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
103- }
104- case domain.AuthTypeToken:
105- authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log)
106- default:
107- log.Error().
108- Str("auth_type", string(authSvc.GetAuthType())).
109- Msg("unknown auth type")
110- authenticated = false
111- }
112 97
113 if !authenticated {98 if !authenticated {
114 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)99 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
@@ -132,34 +117,6 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
132 }117 }
133 }118 }
134 119
135-// authenticatePassword handles password-based authentication.
136-func authenticatePassword(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) bool {
137- username, password, ok := r.BasicAuth()
138- if !ok {
139- log.Debug().
140- Str(zerowrap.FieldMethod, r.Method).
141- Str(zerowrap.FieldPath, r.URL.Path).
142- Msg("no basic auth provided")
143- return false
144- }
145-
146- if authSvc.ValidatePassword(ctx, username, password) {
147- log.Debug().
148- Str("username", username).
149- Str(zerowrap.FieldMethod, r.Method).
150- Str(zerowrap.FieldPath, r.URL.Path).
151- Msg("password authentication successful")
152- return true
153- }
154-
155- log.Debug().
156- Str("provided_username", redactUsername(username)).
157- Str(zerowrap.FieldMethod, r.Method).
158- Str(zerowrap.FieldPath, r.URL.Path).
159- Msg("password authentication failed")
160- return false
161-}
162-
163 // authenticateToken handles token-based authentication.120 // authenticateToken handles token-based authentication.
164 // It supports both Bearer token in Authorization header and token-as-password for CI.121 // It supports both Bearer token in Authorization header and token-as-password for CI.
165 // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.122 // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure.
@@ -292,32 +249,14 @@ func checkScopeAccess(r *http.Request, claims *domain.TokenClaims, log zerowrap.
292 return true249 return true
293 }250 }
294 251
295- // Check if any token scope grants access252+ // Delegate matching to domain layer
296- for _, scopeStr := range claims.Scopes {253+ if domain.ScopesGrantRegistryAccess(claims.Scopes, repoName, action) {
297- // Handle simple scopes (e.g., "push", "pull") for backwards compatibility254+ log.Debug().
298- if scopeStr == action || scopeStr == "*" {255+ Str("repo", repoName).
299- log.Debug().256+ Str("action", action).
300- Str("repo", repoName).257+ Strs("scopes", claims.Scopes).
301- Str("action", action).258+ Msg("scope access granted")
302- Str("scope", scopeStr).259+ return true
303- Msg("simple scope access granted")
304- return true
305- }
306-
307- // Handle Docker v2 format scopes (e.g., "repository:myrepo:push,pull")
308- scope, err := domain.ParseScope(scopeStr)
309- if err != nil {
310- continue
311- }
312-
313- if scope.CanAccess(repoName, action) {
314- log.Debug().
315- Str("repo", repoName).
316- Str("action", action).
317- Str("scope", scopeStr).
318- Msg("scope access granted")
319- return true
320- }
321 }260 }
322 261
323 log.Debug().262 log.Debug().
M internal/adapters/in/http/middleware/auth_test.go +27 -35
@@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger {
2020
2121 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
2222 log := testLogger()
23- called := false
23+ tokenCalled := false
2424 authSvc := stubAuthService{
2525 enabled: true,
26- authType: domain.AuthTypePassword,
27- validatePassword: func(_ context.Context, _ string, _ string) bool {
28- called = true
29- return false
26+ authType: domain.AuthTypeToken,
27+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
28+ tokenCalled = true
29+ return nil, errors.New("not a token")
3030 },
3131 }
3232 internalAuth := InternalRegistryAuth{
@@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
4646 handler.ServeHTTP(rec, req)
4747
4848 assert.Equal(t, http.StatusOK, rec.Code)
49- assert.False(t, called, "auth service should not be used for internal loopback auth")
49+ assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth")
5050 }
5151
5252 func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
5353 log := testLogger()
54- called := false
54+ tokenCalled := false
5555 authSvc := stubAuthService{
5656 enabled: true,
57- authType: domain.AuthTypePassword,
58- validatePassword: func(_ context.Context, _ string, _ string) bool {
59- called = true
60- return false
57+ authType: domain.AuthTypeToken,
58+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
59+ tokenCalled = true
60+ return nil, errors.New("invalid token")
6161 },
6262 }
6363 internalAuth := InternalRegistryAuth{
@@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
7777 handler.ServeHTTP(rec, req)
7878
7979 assert.Equal(t, http.StatusUnauthorized, rec.Code)
80- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
81- assert.True(t, called, "auth service should be used when internal auth fails")
80+ assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
81+ assert.True(t, tokenCalled, "auth service should be used when internal auth fails")
8282 }
8383
8484 func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
8585 log := testLogger()
86- called := false
86+ tokenCalled := false
8787 authSvc := stubAuthService{
8888 enabled: true,
89- authType: domain.AuthTypePassword,
90- validatePassword: func(_ context.Context, _ string, _ string) bool {
91- called = true
92- return false
89+ authType: domain.AuthTypeToken,
90+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
91+ tokenCalled = true
92+ return nil, errors.New("invalid token")
9393 },
9494 }
9595 internalAuth := InternalRegistryAuth{
@@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
109109 handler.ServeHTTP(rec, req)
110110
111111 assert.Equal(t, http.StatusUnauthorized, rec.Code)
112- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
113- assert.True(t, called, "auth service should be used for non-localhost requests")
112+ assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
113+ assert.True(t, tokenCalled, "auth service should be used for non-localhost requests")
114114 }
115115
116116 type stubAuthService struct {
117- enabled bool
118- authType domain.AuthType
119- validatePassword func(ctx context.Context, username, password string) bool
120- validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
117+ enabled bool
118+ authType domain.AuthType
119+ validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
121120 }
122121
123122 func (s stubAuthService) GetAuthType() domain.AuthType {
@@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool {
128127 return s.enabled
129128 }
130129
131-func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
132- if s.validatePassword != nil {
133- return s.validatePassword(ctx, username, password)
134- }
135- return false
136-}
137-
138130 func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
139131 if s.validateToken != nil {
140132 return s.validateToken(ctx, tokenString)
@@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
162154 return nil, errors.New("not implemented")
163155 }
164156
165-func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
166- return "", errors.New("not implemented")
167-}
168-
169157 func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
170158 return nil, errors.New("not implemented")
171159 }
@@ -174,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174162 return "", errors.New("not implemented")
175163 }
176164
165+func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166+ return 15 * time.Minute
167+}
168+
177169 // Tests for checkScopeAccess function
178170
179171 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
@@ -20,13 +20,13 @@ func testLogger() zerowrap.Logger {
20 20
21 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {21 func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
22 log := testLogger()22 log := testLogger()
23- called := false23+ tokenCalled := false
24 authSvc := stubAuthService{24 authSvc := stubAuthService{
25 enabled: true,25 enabled: true,
26- authType: domain.AuthTypePassword,26+ authType: domain.AuthTypeToken,
27- validatePassword: func(_ context.Context, _ string, _ string) bool {27+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
28- called = true28+ tokenCalled = true
29- return false29+ return nil, errors.New("not a token")
30 },30 },
31 }31 }
32 internalAuth := InternalRegistryAuth{32 internalAuth := InternalRegistryAuth{
@@ -46,18 +46,18 @@ func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
46 handler.ServeHTTP(rec, req)46 handler.ServeHTTP(rec, req)
47 47
48 assert.Equal(t, http.StatusOK, rec.Code)48 assert.Equal(t, http.StatusOK, rec.Code)
49- assert.False(t, called, "auth service should not be used for internal loopback auth")49+ assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth")
50 }50 }
51 51
52 func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {52 func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53 log := testLogger()53 log := testLogger()
54- called := false54+ tokenCalled := false
55 authSvc := stubAuthService{55 authSvc := stubAuthService{
56 enabled: true,56 enabled: true,
57- authType: domain.AuthTypePassword,57+ authType: domain.AuthTypeToken,
58- validatePassword: func(_ context.Context, _ string, _ string) bool {58+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
59- called = true59+ tokenCalled = true
60- return false60+ return nil, errors.New("invalid token")
61 },61 },
62 }62 }
63 internalAuth := InternalRegistryAuth{63 internalAuth := InternalRegistryAuth{
@@ -77,19 +77,19 @@ func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
77 handler.ServeHTTP(rec, req)77 handler.ServeHTTP(rec, req)
78 78
79 assert.Equal(t, http.StatusUnauthorized, rec.Code)79 assert.Equal(t, http.StatusUnauthorized, rec.Code)
80- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))80+ assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
81- assert.True(t, called, "auth service should be used when internal auth fails")81+ assert.True(t, tokenCalled, "auth service should be used when internal auth fails")
82 }82 }
83 83
84 func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {84 func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85 log := testLogger()85 log := testLogger()
86- called := false86+ tokenCalled := false
87 authSvc := stubAuthService{87 authSvc := stubAuthService{
88 enabled: true,88 enabled: true,
89- authType: domain.AuthTypePassword,89+ authType: domain.AuthTypeToken,
90- validatePassword: func(_ context.Context, _ string, _ string) bool {90+ validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
91- called = true91+ tokenCalled = true
92- return false92+ return nil, errors.New("invalid token")
93 },93 },
94 }94 }
95 internalAuth := InternalRegistryAuth{95 internalAuth := InternalRegistryAuth{
@@ -109,15 +109,14 @@ func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
109 handler.ServeHTTP(rec, req)109 handler.ServeHTTP(rec, req)
110 110
111 assert.Equal(t, http.StatusUnauthorized, rec.Code)111 assert.Equal(t, http.StatusUnauthorized, rec.Code)
112- assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))112+ assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
113- assert.True(t, called, "auth service should be used for non-localhost requests")113+ assert.True(t, tokenCalled, "auth service should be used for non-localhost requests")
114 }114 }
115 115
116 type stubAuthService struct {116 type stubAuthService struct {
117- enabled bool117+ enabled bool
118- authType domain.AuthType118+ authType domain.AuthType
119- validatePassword func(ctx context.Context, username, password string) bool119+ validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
120- validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
121 }120 }
122 121
123 func (s stubAuthService) GetAuthType() domain.AuthType {122 func (s stubAuthService) GetAuthType() domain.AuthType {
@@ -128,13 +127,6 @@ func (s stubAuthService) IsEnabled() bool {
128 return s.enabled127 return s.enabled
129 }128 }
130 129
131-func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
132- if s.validatePassword != nil {
133- return s.validatePassword(ctx, username, password)
134- }
135- return false
136-}
137-
138 func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {130 func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
139 if s.validateToken != nil {131 if s.validateToken != nil {
140 return s.validateToken(ctx, tokenString)132 return s.validateToken(ctx, tokenString)
@@ -162,10 +154,6 @@ func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
162 return nil, errors.New("not implemented")154 return nil, errors.New("not implemented")
163 }155 }
164 156
165-func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
166- return "", errors.New("not implemented")
167-}
168-
169 func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {157 func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
170 return nil, errors.New("not implemented")158 return nil, errors.New("not implemented")
171 }159 }
@@ -174,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
174 return "", errors.New("not implemented")162 return "", errors.New("not implemented")
175 }163 }
176 164
165+func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166+ return 15 * time.Minute
167+}
168+
177 // Tests for checkScopeAccess function169 // Tests for checkScopeAccess function
178 170
179 func TestCheckScopeAccess_ActionMapping(t *testing.T) {171 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
M internal/adapters/in/http/proxy/forwarder.go +0 -5
@@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
163163 // and could conflict with Gordon's own security headers on browser-facing
164164 // routes. We strip them here to ensure clean, predictable registry responses.
165165 ModifyResponse: func(resp *http.Response) error {
166- resp.Header.Set("X-Proxied-By", "Gordon")
167- resp.Header.Set("X-Registry-Backend", "gordon-registry")
168-
169166 // Remove browser-oriented security headers injected by the upstream
170167 // registry. These are unnecessary for Docker client API traffic and
171168 // would conflict with headers set on browser-facing proxy routes.
@@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
213210 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
214211 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215212 return func(resp *http.Response) error {
216- resp.Header.Set("X-Proxied-By", "Gordon")
217-
218213 if maxResponseSize > 0 {
219214 if resp.ContentLength > maxResponseSize {
220215 resp.Body.Close()
@@ -163,9 +163,6 @@ func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, regi
163 // and could conflict with Gordon's own security headers on browser-facing163 // and could conflict with Gordon's own security headers on browser-facing
164 // routes. We strip them here to ensure clean, predictable registry responses.164 // routes. We strip them here to ensure clean, predictable registry responses.
165 ModifyResponse: func(resp *http.Response) error {165 ModifyResponse: func(resp *http.Response) error {
166- resp.Header.Set("X-Proxied-By", "Gordon")
167- resp.Header.Set("X-Registry-Backend", "gordon-registry")
168-
169 // Remove browser-oriented security headers injected by the upstream166 // Remove browser-oriented security headers injected by the upstream
170 // registry. These are unnecessary for Docker client API traffic and167 // registry. These are unnecessary for Docker client API traffic and
171 // would conflict with headers set on browser-facing proxy routes.168 // would conflict with headers set on browser-facing proxy routes.
@@ -213,8 +210,6 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou
213 // modifyResponse returns a function that adds proxy headers and enforces response size limits.210 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
214 func modifyResponse(maxResponseSize int64) func(*http.Response) error {211 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215 return func(resp *http.Response) error {212 return func(resp *http.Response) error {
216- resp.Header.Set("X-Proxied-By", "Gordon")
217-
218 if maxResponseSize > 0 {213 if maxResponseSize > 0 {
219 if resp.ContentLength > maxResponseSize {214 if resp.ContentLength > maxResponseSize {
220 resp.Body.Close()215 resp.Body.Close()
M internal/adapters/in/http/proxy/forwarder_test.go +0 -1
@@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
197197 handler.ServeHTTP(w, req)
198198
199199 assert.Equal(t, http.StatusOK, w.Code)
200- assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201200 }
@@ -197,5 +197,4 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
197 handler.ServeHTTP(w, req)197 handler.ServeHTTP(w, req)
198 198
199 assert.Equal(t, http.StatusOK, w.Code)199 assert.Equal(t, http.StatusOK, w.Code)
200- assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201 }200 }
M internal/adapters/out/docker/runtime.go +8 -0
@@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161161 Labels: config.Labels,
162162 }
163163
164+ resources := container.Resources{
165+ Memory: config.MemoryLimit,
166+ NanoCPUs: config.NanoCPUs,
167+ }
168+ if config.PidsLimit > 0 {
169+ resources.PidsLimit = &config.PidsLimit
170+ }
164171 hostConfig := &container.HostConfig{
165172 PortBindings: portBindings,
166173 AutoRemove: config.AutoRemove,
167174 Binds: binds,
168175 NetworkMode: container.NetworkMode(config.NetworkMode),
176+ Resources: resources,
169177 }
170178
171179 // Create network configuration for container
@@ -161,11 +161,19 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
161 Labels: config.Labels,161 Labels: config.Labels,
162 }162 }
163 163
164+ resources := container.Resources{
165+ Memory: config.MemoryLimit,
166+ NanoCPUs: config.NanoCPUs,
167+ }
168+ if config.PidsLimit > 0 {
169+ resources.PidsLimit = &config.PidsLimit
170+ }
164 hostConfig := &container.HostConfig{171 hostConfig := &container.HostConfig{
165 PortBindings: portBindings,172 PortBindings: portBindings,
166 AutoRemove: config.AutoRemove,173 AutoRemove: config.AutoRemove,
167 Binds: binds,174 Binds: binds,
168 NetworkMode: container.NetworkMode(config.NetworkMode),175 NetworkMode: container.NetworkMode(config.NetworkMode),
176+ Resources: resources,
169 }177 }
170 178
171 // Create network configuration for container179 // Create network configuration for container
M internal/adapters/out/domainsecrets/store.go +18 -0
@@ -219,9 +219,23 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e
219219 return secrets, nil
220220 }
221221
222+// validateSecretValues checks that no secret value contains newline characters.
223+func validateSecretValues(secrets map[string]string) error {
224+ for key, value := range secrets {
225+ if strings.ContainsAny(value, "\n\r") {
226+ return fmt.Errorf("secret value for %q contains newline characters", key)
227+ }
228+ }
229+ return nil
230+}
231+
222232 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
223233 // It writes to a temporary file first, syncs it, then renames to the final path.
224234 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
235+ if err := validateSecretValues(secrets); err != nil {
236+ return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err)
237+ }
238+
225239 envFile, err := s.validateEnvFilePath(domainName)
226240 if err != nil {
227241 return err
@@ -363,6 +377,10 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string,
363377 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
364378 // It writes to a temporary file first, syncs it, then renames to the final path.
365379 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
380+ if err := validateSecretValues(secrets); err != nil {
381+ return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err)
382+ }
383+
366384 envFile, err := s.validateAttachmentEnvFilePath(containerName)
367385 if err != nil {
368386 return err
@@ -219,9 +219,23 @@ func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, e
219 return secrets, nil219 return secrets, nil
220 }220 }
221 221
222+// validateSecretValues checks that no secret value contains newline characters.
223+func validateSecretValues(secrets map[string]string) error {
224+ for key, value := range secrets {
225+ if strings.ContainsAny(value, "\n\r") {
226+ return fmt.Errorf("secret value for %q contains newline characters", key)
227+ }
228+ }
229+ return nil
230+}
231+
222 // writeSecretsAtomic writes all secrets to the domain's env file atomically.232 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
223 // It writes to a temporary file first, syncs it, then renames to the final path.233 // It writes to a temporary file first, syncs it, then renames to the final path.
224 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {234 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
235+ if err := validateSecretValues(secrets); err != nil {
236+ return fmt.Errorf("invalid domain secret values for %q: %w", domainName, err)
237+ }
238+
225 envFile, err := s.validateEnvFilePath(domainName)239 envFile, err := s.validateEnvFilePath(domainName)
226 if err != nil {240 if err != nil {
227 return err241 return err
@@ -363,6 +377,10 @@ func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string,
363 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.377 // writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
364 // It writes to a temporary file first, syncs it, then renames to the final path.378 // It writes to a temporary file first, syncs it, then renames to the final path.
365 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {379 func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
380+ if err := validateSecretValues(secrets); err != nil {
381+ return fmt.Errorf("invalid attachment secret values for %q: %w", containerName, err)
382+ }
383+
366 envFile, err := s.validateAttachmentEnvFilePath(containerName)384 envFile, err := s.validateAttachmentEnvFilePath(containerName)
367 if err != nil {385 if err != nil {
368 return err386 return err
M internal/app/run.go +127 -78
@@ -127,13 +127,12 @@ type Config struct {
127127
128128 Auth struct {
129129 Enabled bool `mapstructure:"enabled"`
130- Type string `mapstructure:"type"` // "password" or "token"
130+ Type string `mapstructure:"type"` // only "token" is supported
131131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
132132 Username string `mapstructure:"username"`
133- Password string `mapstructure:"password"` // deprecated: use password_hash
134- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
135- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
136- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
133+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
134+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
135+ AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
137136 } `mapstructure:"auth"`
138137
139138 API struct {
@@ -166,6 +165,12 @@ type Config struct {
166165 } `mapstructure:"prune"`
167166 } `mapstructure:"images"`
168167
168+ Containers struct {
169+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
170+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
171+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
172+ } `mapstructure:"containers"`
173+
169174 Telemetry telemetry.Config `mapstructure:"telemetry"`
170175 }
171176
@@ -990,7 +995,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
990995 return nil, nil, nil
991996 }
992997
993- authType := resolveAuthType(cfg)
998+ authType, err := resolveAuthType(cfg)
999+ if err != nil {
1000+ return nil, nil, fmt.Errorf("resolve auth type: %w", err)
1001+ }
9941002 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
9951003 if err != nil {
9961004 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
@@ -1018,25 +1026,12 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
10181026 }
10191027
10201028 // resolveAuthType determines the auth type from config.
1021-// If password_hash is configured, password auth is available (plus tokens).
1022-// If only token_secret is configured, token-only mode.
1023-// The explicit "type" field is deprecated but still respected for backwards compat.
1024-func resolveAuthType(cfg Config) domain.AuthType {
1025- // Explicit type takes precedence (backwards compatibility)
1026- if cfg.Auth.Type == "token" {
1027- return domain.AuthTypeToken
1028- }
1029- if cfg.Auth.Type == "password" {
1030- return domain.AuthTypePassword
1029+// Token-only authentication is the only supported mode.
1030+func resolveAuthType(cfg Config) (domain.AuthType, error) {
1031+ if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
1032+ return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
10311033 }
1032-
1033- // Infer from config: password auth if password_hash is configured
1034- if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
1035- return domain.AuthTypePassword
1036- }
1037-
1038- // Default to token-only
1039- return domain.AuthTypeToken
1034+ return domain.AuthTypeToken, nil
10401035 }
10411036
10421037 func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
@@ -1094,41 +1089,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
10941089 authConfig.TokenSecret = secret
10951090 authConfig.TokenExpiry = expiry
10961091
1097- // Password config only needed for password auth mode
1098- if authType == domain.AuthTypePassword {
1099- hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
1092+ accessTokenTTL := 15 * time.Minute // default
1093+ if cfg.Auth.AccessTokenTTL != "" {
1094+ parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
11001095 if err != nil {
1101- return auth.Config{}, err
1096+ return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
11021097 }
1103- if hash == "" {
1104- return auth.Config{}, errAuthNotConfigured()
1098+ if parsed <= 0 {
1099+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
11051100 }
1106- authConfig.PasswordHash = hash
1107- }
1108-
1109- return authConfig, nil
1110-}
1111-
1112-// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
1113-func errAuthNotConfigured() error {
1114- return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
1115-}
1116-
1117-func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
1118- if cfg.Auth.PasswordHash != "" {
1119- hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
1120- if err != nil {
1121- return "", log.WrapErr(err, "failed to load password hash")
1101+ if parsed > auth.MaxAccessTokenLifetime {
1102+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
11221103 }
1123- return hash, nil
1124- }
1125-
1126- if cfg.Auth.Password != "" {
1127- log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
1128- return cfg.Auth.Password, nil
1104+ accessTokenTTL = parsed
11291105 }
1106+ authConfig.AccessTokenTTL = accessTokenTTL
11301107
1131- return "", nil
1108+ return authConfig, nil
11321109 }
11331110
11341111 func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
@@ -1155,8 +1132,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
11551132 // 2. Secrets backend (pass/sops - encrypted)
11561133 // 3. Config file path (least preferred)
11571134
1135+ const minTokenSecretLength = 32
1136+
11581137 // Check environment variable first
11591138 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1139+ if len(envSecret) < minTokenSecretLength {
1140+ return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1141+ }
11601142 log.Debug().Msg("using token secret from environment variable")
11611143 return []byte(envSecret), nil
11621144 }
@@ -1171,6 +1153,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
11711153 return nil, log.WrapErr(err, "failed to load token secret")
11721154 }
11731155
1156+ if len(secret) < minTokenSecretLength {
1157+ return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1158+ }
1159+
11741160 return []byte(secret), nil
11751161 }
11761162
@@ -1275,6 +1261,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
12751261
12761262 // createContainerService creates the container service with configuration.
12771263 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1264+ // Parse and validate container resource limits from config
1265+ if cfg.Containers.CPULimit < 0 {
1266+ return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1267+ }
1268+ if cfg.Containers.PidsLimit < 0 {
1269+ return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1270+ }
1271+ var defaultMemoryLimit int64
1272+ if cfg.Containers.MemoryLimit != "" {
1273+ parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1274+ if err != nil {
1275+ return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1276+ }
1277+ if parsed <= 0 {
1278+ return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1279+ }
1280+ defaultMemoryLimit = parsed
1281+ }
1282+ var defaultNanoCPUs int64
1283+ if cfg.Containers.CPULimit > 0 {
1284+ defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1285+ }
1286+
12781287 containerConfig := container.Config{
12791288 RegistryAuthEnabled: cfg.Auth.Enabled,
12801289 RegistryDomain: cfg.Server.RegistryDomain,
@@ -1298,6 +1307,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
12981307 DrainDelay: v.GetDuration("deploy.drain_delay"),
12991308 DrainMode: v.GetString("deploy.drain_mode"),
13001309 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1310+ DefaultMemoryLimit: defaultMemoryLimit,
1311+ DefaultNanoCPUs: defaultNanoCPUs,
1312+ DefaultPidsLimit: cfg.Containers.PidsLimit,
13011313 }
13021314 if v.IsSet("deploy.drain_delay") {
13031315 containerConfig.DrainDelayConfigured = true
@@ -1542,7 +1554,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
15421554 )
15431555
15441556 registryMux := http.NewServeMux()
1545- registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)
1557+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
15461558 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
15471559
15481560 // SECURITY: No CORS middleware on the proxy chain. Backend applications
@@ -1675,12 +1687,22 @@ func registerAuthRoutes(
16751687 trustedNets []*net.IPNet,
16761688 cidrAllowlistMiddleware func(http.Handler) http.Handler,
16771689 rateLimitMiddleware func(http.Handler) http.Handler,
1690+ cfg Config,
16781691 log zerowrap.Logger,
16791692 ) {
16801693 if svc.authHandler == nil {
16811694 return
16821695 }
16831696
1697+ // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
1698+ // This prevents brute-force attacks against password/token endpoints.
1699+ authRateLimitMiddleware := rateLimitMiddleware
1700+ if !cfg.API.RateLimit.Enabled {
1701+ authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
1702+ authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
1703+ authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
1704+ }
1705+
16841706 // Auth endpoints are NOT protected by auth - they're where clients authenticate
16851707 // but still need rate limiting to prevent brute force attacks.
16861708 authMiddlewares := []func(http.Handler) http.Handler{
@@ -1691,7 +1713,7 @@ func registerAuthRoutes(
16911713 if cidrAllowlistMiddleware != nil {
16921714 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
16931715 }
1694- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)
1716+ authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
16951717 authWithMiddleware := otelhttp.NewHandler(
16961718 middleware.Chain(authMiddlewares...)(svc.authHandler),
16971719 "gordon.auth",
@@ -1774,27 +1796,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17741796
17751797 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
17761798
1777- // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can
1778- // accept h2c traffic from clients and load balancers (e.g. Cloudflare).
1779- var proxyProtos http.Protocols
1780- proxyProtos.SetHTTP1(true)
1781- proxyProtos.SetUnencryptedHTTP2(true)
1782- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1783- var tlsSrv *http.Server
1784- var tlsReady <-chan struct{}
1785- if cfg.Server.TLSEnabled {
1786- if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1787- return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1788- }
1789- tlsSrv, tlsReady = startTLSServer(
1790- fmt.Sprintf(":%d", cfg.Server.TLSPort),
1791- proxyHandler,
1792- "proxy-tls",
1793- cfg.Server.TLSCertFile,
1794- cfg.Server.TLSKeyFile,
1795- errChan,
1796- log,
1797- )
1799+ proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)
1800+ if err != nil {
1801+ return err
17981802 }
17991803
18001804 // Wait for all enabled servers to bind their ports before auto-starting containers.
@@ -2075,6 +2079,52 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
20752079 log.Info().Msg("Gordon stopped")
20762080 }
20772081
2082+// startProxyServers sets up proxy server(s) depending on TLS configuration.
2083+// With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic.
2084+// Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2.
2085+func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
2086+ if !cfg.Server.TLSEnabled {
2087+ var proxyProtos http.Protocols
2088+ proxyProtos.SetHTTP1(true)
2089+ proxyProtos.SetUnencryptedHTTP2(true)
2090+ srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
2091+ return srv, ready, nil, nil, nil
2092+ }
2093+
2094+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
2095+ return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
2096+ }
2097+
2098+ tlsPort := cfg.Server.TLSPort
2099+ redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2100+ host := r.Host
2101+ if h, _, err := net.SplitHostPort(host); err == nil {
2102+ host = h
2103+ } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
2104+ host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
2105+ }
2106+ if tlsPort != 443 {
2107+ host = net.JoinHostPort(host, strconv.Itoa(tlsPort))
2108+ } else if strings.Contains(host, ":") {
2109+ host = "[" + host + "]"
2110+ }
2111+ target := "https://" + host + r.RequestURI
2112+ http.Redirect(w, r, target, http.StatusPermanentRedirect)
2113+ })
2114+
2115+ proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log)
2116+ tlsSrv, tlsReady := startTLSServer(
2117+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
2118+ proxyHandler,
2119+ "proxy-tls",
2120+ cfg.Server.TLSCertFile,
2121+ cfg.Server.TLSKeyFile,
2122+ errChan,
2123+ log,
2124+ )
2125+ return proxySrv, proxyReady, tlsSrv, tlsReady, nil
2126+}
2127+
20782128 // startServer starts an HTTP server, returning the server instance and a channel
20792129 // that closes once the listening socket is bound. This lets callers wait for the
20802130 // port to be ready before taking actions that depend on it (e.g. auto-start
@@ -2421,8 +2471,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
24212471 v.SetDefault("logging.container_logs.max_age", 28)
24222472 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
24232473 v.SetDefault("auth.enabled", true)
2424- // Note: auth.type is intentionally not set - it's inferred from config
2425- // If password_hash is set -> password mode, otherwise -> token mode
2474+ // Note: auth.type defaults to "token" (the only supported mode)
24262475 v.SetDefault("auth.secrets_backend", "")
24272476 v.SetDefault("auth.token_expiry", "720h")
24282477 v.SetDefault("api.rate_limit.enabled", true)
@@ -127,13 +127,12 @@ type Config struct {
127 127
128 Auth struct {128 Auth struct {
129 Enabled bool `mapstructure:"enabled"`129 Enabled bool `mapstructure:"enabled"`
130- Type string `mapstructure:"type"` // "password" or "token"130+ Type string `mapstructure:"type"` // only "token" is supported
131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
132 Username string `mapstructure:"username"`132 Username string `mapstructure:"username"`
133- Password string `mapstructure:"password"` // deprecated: use password_hash133+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
134- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend134+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
135- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend135+ AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
136- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
137 } `mapstructure:"auth"`136 } `mapstructure:"auth"`
138 137
139 API struct {138 API struct {
@@ -166,6 +165,12 @@ type Config struct {
166 } `mapstructure:"prune"`165 } `mapstructure:"prune"`
167 } `mapstructure:"images"`166 } `mapstructure:"images"`
168 167
168+ Containers struct {
169+ MemoryLimit string `mapstructure:"memory_limit"` // e.g., "512MB", "1GB"
170+ CPULimit float64 `mapstructure:"cpu_limit"` // CPU cores, e.g., 1.0 = 1 core
171+ PidsLimit int64 `mapstructure:"pids_limit"` // e.g., 512
172+ } `mapstructure:"containers"`
173+
169 Telemetry telemetry.Config `mapstructure:"telemetry"`174 Telemetry telemetry.Config `mapstructure:"telemetry"`
170 }175 }
171 176
@@ -990,7 +995,10 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
990 return nil, nil, nil995 return nil, nil, nil
991 }996 }
992 997
993- authType := resolveAuthType(cfg)998+ authType, err := resolveAuthType(cfg)
999+ if err != nil {
1000+ return nil, nil, fmt.Errorf("resolve auth type: %w", err)
1001+ }
994 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)1002 backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend)
995 if err != nil {1003 if err != nil {
996 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")1004 return nil, nil, log.WrapErr(err, "failed to resolve secrets backend")
@@ -1018,25 +1026,12 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou
1018 }1026 }
1019 1027
1020 // resolveAuthType determines the auth type from config.1028 // resolveAuthType determines the auth type from config.
1021-// If password_hash is configured, password auth is available (plus tokens).1029+// Token-only authentication is the only supported mode.
1022-// If only token_secret is configured, token-only mode.1030+func resolveAuthType(cfg Config) (domain.AuthType, error) {
1023-// The explicit "type" field is deprecated but still respected for backwards compat.1031+ if cfg.Auth.Type != "" && cfg.Auth.Type != "token" {
1024-func resolveAuthType(cfg Config) domain.AuthType {1032+ return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type)
1025- // Explicit type takes precedence (backwards compatibility)
1026- if cfg.Auth.Type == "token" {
1027- return domain.AuthTypeToken
1028- }
1029- if cfg.Auth.Type == "password" {
1030- return domain.AuthTypePassword
1031 }1033 }
1032-1034+ return domain.AuthTypeToken, nil
1033- // Infer from config: password auth if password_hash is configured
1034- if cfg.Auth.PasswordHash != "" || cfg.Auth.Password != "" {
1035- return domain.AuthTypePassword
1036- }
1037-
1038- // Default to token-only
1039- return domain.AuthTypeToken
1040 }1035 }
1041 1036
1042 func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {1037 func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) {
@@ -1094,41 +1089,23 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
1094 authConfig.TokenSecret = secret1089 authConfig.TokenSecret = secret
1095 authConfig.TokenExpiry = expiry1090 authConfig.TokenExpiry = expiry
1096 1091
1097- // Password config only needed for password auth mode1092+ accessTokenTTL := 15 * time.Minute // default
1098- if authType == domain.AuthTypePassword {1093+ if cfg.Auth.AccessTokenTTL != "" {
1099- hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)1094+ parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
1100 if err != nil {1095 if err != nil {
1101- return auth.Config{}, err1096+ return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
1102 }1097 }
1103- if hash == "" {1098+ if parsed <= 0 {
1104- return auth.Config{}, errAuthNotConfigured()1099+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
1105 }1100 }
1106- authConfig.PasswordHash = hash1101+ if parsed > auth.MaxAccessTokenLifetime {
1107- }1102+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime)
1108-
1109- return authConfig, nil
1110-}
1111-
1112-// errAuthNotConfigured returns an error when auth is enabled but credentials are not configured.
1113-func errAuthNotConfigured() error {
1114- return fmt.Errorf("auth is enabled by default, configure auth type and secrets backend. See: https://gordon.bnema.dev/docs/config/auth")
1115-}
1116-
1117-func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
1118- if cfg.Auth.PasswordHash != "" {
1119- hash, err := loadSecret(ctx, backend, cfg.Auth.PasswordHash, dataDir, log)
1120- if err != nil {
1121- return "", log.WrapErr(err, "failed to load password hash")
1122 }1103 }
1123- return hash, nil1104+ accessTokenTTL = parsed
1124- }
1125-
1126- if cfg.Auth.Password != "" {
1127- log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
1128- return cfg.Auth.Password, nil
1129 }1105 }
1106+ authConfig.AccessTokenTTL = accessTokenTTL
1130 1107
1131- return "", nil1108+ return authConfig, nil
1132 }1109 }
1133 1110
1134 func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {1111 func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
@@ -1155,8 +1132,13 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
1155 // 2. Secrets backend (pass/sops - encrypted)1132 // 2. Secrets backend (pass/sops - encrypted)
1156 // 3. Config file path (least preferred)1133 // 3. Config file path (least preferred)
1157 1134
1135+ const minTokenSecretLength = 32
1136+
1158 // Check environment variable first1137 // Check environment variable first
1159 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {1138 if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" {
1139+ if len(envSecret) < minTokenSecretLength {
1140+ return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret))
1141+ }
1160 log.Debug().Msg("using token secret from environment variable")1142 log.Debug().Msg("using token secret from environment variable")
1161 return []byte(envSecret), nil1143 return []byte(envSecret), nil
1162 }1144 }
@@ -1171,6 +1153,10 @@ func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBack
1171 return nil, log.WrapErr(err, "failed to load token secret")1153 return nil, log.WrapErr(err, "failed to load token secret")
1172 }1154 }
1173 1155
1156+ if len(secret) < minTokenSecretLength {
1157+ return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret))
1158+ }
1159+
1174 return []byte(secret), nil1160 return []byte(secret), nil
1175 }1161 }
1176 1162
@@ -1275,6 +1261,29 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1275 1261
1276 // createContainerService creates the container service with configuration.1262 // createContainerService creates the container service with configuration.
1277 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {1263 func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
1264+ // Parse and validate container resource limits from config
1265+ if cfg.Containers.CPULimit < 0 {
1266+ return nil, fmt.Errorf("containers.cpu_limit must be >= 0 (got %f)", cfg.Containers.CPULimit)
1267+ }
1268+ if cfg.Containers.PidsLimit < 0 {
1269+ return nil, fmt.Errorf("containers.pids_limit must be >= 0 (got %d)", cfg.Containers.PidsLimit)
1270+ }
1271+ var defaultMemoryLimit int64
1272+ if cfg.Containers.MemoryLimit != "" {
1273+ parsed, err := bytesize.Parse(cfg.Containers.MemoryLimit)
1274+ if err != nil {
1275+ return nil, fmt.Errorf("invalid containers.memory_limit %q: %w", cfg.Containers.MemoryLimit, err)
1276+ }
1277+ if parsed <= 0 {
1278+ return nil, fmt.Errorf("containers.memory_limit must be positive (got %q)", cfg.Containers.MemoryLimit)
1279+ }
1280+ defaultMemoryLimit = parsed
1281+ }
1282+ var defaultNanoCPUs int64
1283+ if cfg.Containers.CPULimit > 0 {
1284+ defaultNanoCPUs = int64(cfg.Containers.CPULimit * 1e9)
1285+ }
1286+
1278 containerConfig := container.Config{1287 containerConfig := container.Config{
1279 RegistryAuthEnabled: cfg.Auth.Enabled,1288 RegistryAuthEnabled: cfg.Auth.Enabled,
1280 RegistryDomain: cfg.Server.RegistryDomain,1289 RegistryDomain: cfg.Server.RegistryDomain,
@@ -1298,6 +1307,9 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1298 DrainDelay: v.GetDuration("deploy.drain_delay"),1307 DrainDelay: v.GetDuration("deploy.drain_delay"),
1299 DrainMode: v.GetString("deploy.drain_mode"),1308 DrainMode: v.GetString("deploy.drain_mode"),
1300 DrainTimeout: v.GetDuration("deploy.drain_timeout"),1309 DrainTimeout: v.GetDuration("deploy.drain_timeout"),
1310+ DefaultMemoryLimit: defaultMemoryLimit,
1311+ DefaultNanoCPUs: defaultNanoCPUs,
1312+ DefaultPidsLimit: cfg.Containers.PidsLimit,
1301 }1313 }
1302 if v.IsSet("deploy.drain_delay") {1314 if v.IsSet("deploy.drain_delay") {
1303 containerConfig.DrainDelayConfigured = true1315 containerConfig.DrainDelayConfigured = true
@@ -1542,7 +1554,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1542 )1554 )
1543 1555
1544 registryMux := http.NewServeMux()1556 registryMux := http.NewServeMux()
1545- registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, log)1557+ registerAuthRoutes(registryMux, svc, trustedNets, cidrAllowlistMiddleware, rateLimitMiddleware, cfg, log)
1546 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))1558 registryMux.Handle("/v2/", wrapRegistryForLocalMode(registryWithMiddleware, cfg, log))
1547 1559
1548 // SECURITY: No CORS middleware on the proxy chain. Backend applications1560 // SECURITY: No CORS middleware on the proxy chain. Backend applications
@@ -1675,12 +1687,22 @@ func registerAuthRoutes(
1675 trustedNets []*net.IPNet,1687 trustedNets []*net.IPNet,
1676 cidrAllowlistMiddleware func(http.Handler) http.Handler,1688 cidrAllowlistMiddleware func(http.Handler) http.Handler,
1677 rateLimitMiddleware func(http.Handler) http.Handler,1689 rateLimitMiddleware func(http.Handler) http.Handler,
1690+ cfg Config,
1678 log zerowrap.Logger,1691 log zerowrap.Logger,
1679 ) {1692 ) {
1680 if svc.authHandler == nil {1693 if svc.authHandler == nil {
1681 return1694 return
1682 }1695 }
1683 1696
1697+ // Auth endpoints always get rate limiting, even if global rate limiting is disabled.
1698+ // This prevents brute-force attacks against password/token endpoints.
1699+ authRateLimitMiddleware := rateLimitMiddleware
1700+ if !cfg.API.RateLimit.Enabled {
1701+ authGlobalLimiter := ratelimit.NewMemoryStore(50, 100, log)
1702+ authIPLimiter := ratelimit.NewMemoryStore(5, 10, log)
1703+ authRateLimitMiddleware = registry.RateLimitMiddleware(authGlobalLimiter, authIPLimiter, cfg.API.RateLimit.TrustedProxies, log)
1704+ }
1705+
1684 // Auth endpoints are NOT protected by auth - they're where clients authenticate1706 // Auth endpoints are NOT protected by auth - they're where clients authenticate
1685 // but still need rate limiting to prevent brute force attacks.1707 // but still need rate limiting to prevent brute force attacks.
1686 authMiddlewares := []func(http.Handler) http.Handler{1708 authMiddlewares := []func(http.Handler) http.Handler{
@@ -1691,7 +1713,7 @@ func registerAuthRoutes(
1691 if cidrAllowlistMiddleware != nil {1713 if cidrAllowlistMiddleware != nil {
1692 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)1714 authMiddlewares = append(authMiddlewares, cidrAllowlistMiddleware)
1693 }1715 }
1694- authMiddlewares = append(authMiddlewares, rateLimitMiddleware)1716+ authMiddlewares = append(authMiddlewares, authRateLimitMiddleware)
1695 authWithMiddleware := otelhttp.NewHandler(1717 authWithMiddleware := otelhttp.NewHandler(
1696 middleware.Chain(authMiddlewares...)(svc.authHandler),1718 middleware.Chain(authMiddlewares...)(svc.authHandler),
1697 "gordon.auth",1719 "gordon.auth",
@@ -1774,27 +1796,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1774 1796
1775 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)1797 registrySrv, registryReady := startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", nil, errChan, log)
1776 1798
1777- // Enable both HTTP/1.1 and cleartext HTTP/2 on the proxy server so Gordon can1799+ proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, proxyHandler, errChan, log)
1778- // accept h2c traffic from clients and load balancers (e.g. Cloudflare).1800+ if err != nil {
1779- var proxyProtos http.Protocols1801+ return err
1780- proxyProtos.SetHTTP1(true)
1781- proxyProtos.SetUnencryptedHTTP2(true)
1782- proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
1783- var tlsSrv *http.Server
1784- var tlsReady <-chan struct{}
1785- if cfg.Server.TLSEnabled {
1786- if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
1787- return fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
1788- }
1789- tlsSrv, tlsReady = startTLSServer(
1790- fmt.Sprintf(":%d", cfg.Server.TLSPort),
1791- proxyHandler,
1792- "proxy-tls",
1793- cfg.Server.TLSCertFile,
1794- cfg.Server.TLSKeyFile,
1795- errChan,
1796- log,
1797- )
1798 }1802 }
1799 1803
1800 // Wait for all enabled servers to bind their ports before auto-starting containers.1804 // Wait for all enabled servers to bind their ports before auto-starting containers.
@@ -2075,6 +2079,52 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
2075 log.Info().Msg("Gordon stopped")2079 log.Info().Msg("Gordon stopped")
2076 }2080 }
2077 2081
2082+// startProxyServers sets up proxy server(s) depending on TLS configuration.
2083+// With TLS enabled, the HTTP port becomes a redirect-only server and a separate TLS server handles traffic.
2084+// Without TLS, a single server handles both HTTP/1.1 and cleartext HTTP/2.
2085+func startProxyServers(cfg Config, proxyHandler http.Handler, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) {
2086+ if !cfg.Server.TLSEnabled {
2087+ var proxyProtos http.Protocols
2088+ proxyProtos.SetHTTP1(true)
2089+ proxyProtos.SetUnencryptedHTTP2(true)
2090+ srv, ready := startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", &proxyProtos, errChan, log)
2091+ return srv, ready, nil, nil, nil
2092+ }
2093+
2094+ if cfg.Server.TLSCertFile == "" || cfg.Server.TLSKeyFile == "" {
2095+ return nil, nil, nil, nil, fmt.Errorf("server.tls_enabled=true requires both server.tls_cert_file and server.tls_key_file")
2096+ }
2097+
2098+ tlsPort := cfg.Server.TLSPort
2099+ redirectHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2100+ host := r.Host
2101+ if h, _, err := net.SplitHostPort(host); err == nil {
2102+ host = h
2103+ } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
2104+ host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
2105+ }
2106+ if tlsPort != 443 {
2107+ host = net.JoinHostPort(host, strconv.Itoa(tlsPort))
2108+ } else if strings.Contains(host, ":") {
2109+ host = "[" + host + "]"
2110+ }
2111+ target := "https://" + host + r.RequestURI
2112+ http.Redirect(w, r, target, http.StatusPermanentRedirect)
2113+ })
2114+
2115+ proxySrv, proxyReady := startServer(fmt.Sprintf(":%d", cfg.Server.Port), redirectHandler, "proxy-redirect", nil, errChan, log)
2116+ tlsSrv, tlsReady := startTLSServer(
2117+ fmt.Sprintf(":%d", cfg.Server.TLSPort),
2118+ proxyHandler,
2119+ "proxy-tls",
2120+ cfg.Server.TLSCertFile,
2121+ cfg.Server.TLSKeyFile,
2122+ errChan,
2123+ log,
2124+ )
2125+ return proxySrv, proxyReady, tlsSrv, tlsReady, nil
2126+}
2127+
2078 // startServer starts an HTTP server, returning the server instance and a channel2128 // startServer starts an HTTP server, returning the server instance and a channel
2079 // that closes once the listening socket is bound. This lets callers wait for the2129 // that closes once the listening socket is bound. This lets callers wait for the
2080 // port to be ready before taking actions that depend on it (e.g. auto-start2130 // port to be ready before taking actions that depend on it (e.g. auto-start
@@ -2421,8 +2471,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2421 v.SetDefault("logging.container_logs.max_age", 28)2471 v.SetDefault("logging.container_logs.max_age", 28)
2422 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty2472 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
2423 v.SetDefault("auth.enabled", true)2473 v.SetDefault("auth.enabled", true)
2424- // Note: auth.type is intentionally not set - it's inferred from config2474+ // Note: auth.type defaults to "token" (the only supported mode)
2425- // If password_hash is set -> password mode, otherwise -> token mode
2426 v.SetDefault("auth.secrets_backend", "")2475 v.SetDefault("auth.secrets_backend", "")
2427 v.SetDefault("auth.token_expiry", "720h")2476 v.SetDefault("auth.token_expiry", "720h")
2428 v.SetDefault("api.rate_limit.enabled", true)2477 v.SetDefault("api.rate_limit.enabled", true)
M internal/app/run_auth_config_test.go +34 -27
@@ -2,59 +2,66 @@ package app
22
33 import (
44 "context"
5- "strings"
65 "testing"
76
87 "github.com/bnema/gordon/internal/domain"
98 "github.com/bnema/zerowrap"
109 "github.com/spf13/viper"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
1112 )
1213
1314 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
14- t.Setenv(TokenSecretEnvVar, "test-token-secret")
15+ t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long")
1516
1617 cfg := Config{}
1718 cfg.Auth.Enabled = false
1819 cfg.Auth.Type = "token"
1920
2021 authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default())
21- if err != nil {
22- t.Fatalf("expected no error, got %v", err)
23- }
24- if authCfg.Enabled {
25- t.Fatalf("expected auth config enabled=false, got true")
26- }
22+ require.NoError(t, err)
23+ assert.False(t, authCfg.Enabled, "expected auth config enabled=false")
2724 }
2825
2926 func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
3027 _, err := resolveSecretsBackend("")
31- if err == nil {
32- t.Fatal("expected error for missing secrets backend")
33- }
34- if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
35- t.Fatalf("unexpected error: %v", err)
36- }
28+ require.Error(t, err)
29+ assert.Contains(t, err.Error(), "auth.secrets_backend is required")
3730 }
3831
3932 func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
4033 _, err := resolveSecretsBackend("vault")
41- if err == nil {
42- t.Fatal("expected error for unsupported secrets backend")
43- }
44- if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {
45- t.Fatalf("unexpected error: %v", err)
46- }
34+ require.Error(t, err)
35+ assert.Contains(t, err.Error(), `unsupported auth.secrets_backend "vault"`)
36+}
37+
38+func TestResolveAuthType_RejectsPassword(t *testing.T) {
39+ cfg := Config{}
40+ cfg.Auth.Type = "password"
41+ _, err := resolveAuthType(cfg)
42+ require.Error(t, err)
43+ assert.Contains(t, err.Error(), "unsupported auth.type")
44+}
45+
46+func TestResolveAuthType_AcceptsToken(t *testing.T) {
47+ cfg := Config{}
48+ cfg.Auth.Type = "token"
49+ authType, err := resolveAuthType(cfg)
50+ require.NoError(t, err)
51+ assert.Equal(t, domain.AuthTypeToken, authType)
52+}
53+
54+func TestResolveAuthType_AcceptsEmpty(t *testing.T) {
55+ cfg := Config{}
56+ authType, err := resolveAuthType(cfg)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.AuthTypeToken, authType)
4759 }
4860
4961 func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
5062 v := viper.New()
5163
5264 err := loadConfig(v, "")
53- if err != nil {
54- t.Fatalf("expected no error, got %v", err)
55- }
56-
57- if got := v.GetString("auth.secrets_backend"); got != "" {
58- t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59- }
65+ require.NoError(t, err)
66+ assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")
6067 }
@@ -2,59 +2,66 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5- "strings"
6 "testing"5 "testing"
7 6
8 "github.com/bnema/gordon/internal/domain"7 "github.com/bnema/gordon/internal/domain"
9 "github.com/bnema/zerowrap"8 "github.com/bnema/zerowrap"
10 "github.com/spf13/viper"9 "github.com/spf13/viper"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
11 )12 )
12 13
13 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {14 func TestBuildAuthConfig_UsesConfigEnabledFlag(t *testing.T) {
14- t.Setenv(TokenSecretEnvVar, "test-token-secret")15+ t.Setenv(TokenSecretEnvVar, "test-token-secret-that-is-at-least-32-bytes-long")
15 16
16 cfg := Config{}17 cfg := Config{}
17 cfg.Auth.Enabled = false18 cfg.Auth.Enabled = false
18 cfg.Auth.Type = "token"19 cfg.Auth.Type = "token"
19 20
20 authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default())21 authCfg, err := buildAuthConfig(context.Background(), cfg, domain.AuthTypeToken, domain.SecretsBackendUnsafe, t.TempDir(), zerowrap.Default())
21- if err != nil {22+ require.NoError(t, err)
22- t.Fatalf("expected no error, got %v", err)23+ assert.False(t, authCfg.Enabled, "expected auth config enabled=false")
23- }
24- if authCfg.Enabled {
25- t.Fatalf("expected auth config enabled=false, got true")
26- }
27 }24 }
28 25
29 func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {26 func TestResolveSecretsBackend_RejectsMissingBackend(t *testing.T) {
30 _, err := resolveSecretsBackend("")27 _, err := resolveSecretsBackend("")
31- if err == nil {28+ require.Error(t, err)
32- t.Fatal("expected error for missing secrets backend")29+ assert.Contains(t, err.Error(), "auth.secrets_backend is required")
33- }
34- if !strings.Contains(err.Error(), "auth.secrets_backend is required") {
35- t.Fatalf("unexpected error: %v", err)
36- }
37 }30 }
38 31
39 func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {32 func TestResolveSecretsBackend_RejectsUnknownBackend(t *testing.T) {
40 _, err := resolveSecretsBackend("vault")33 _, err := resolveSecretsBackend("vault")
41- if err == nil {34+ require.Error(t, err)
42- t.Fatal("expected error for unsupported secrets backend")35+ assert.Contains(t, err.Error(), `unsupported auth.secrets_backend "vault"`)
43- }36+}
44- if !strings.Contains(err.Error(), `unsupported auth.secrets_backend "vault"`) {37+
45- t.Fatalf("unexpected error: %v", err)38+func TestResolveAuthType_RejectsPassword(t *testing.T) {
46- }39+ cfg := Config{}
40+ cfg.Auth.Type = "password"
41+ _, err := resolveAuthType(cfg)
42+ require.Error(t, err)
43+ assert.Contains(t, err.Error(), "unsupported auth.type")
44+}
45+
46+func TestResolveAuthType_AcceptsToken(t *testing.T) {
47+ cfg := Config{}
48+ cfg.Auth.Type = "token"
49+ authType, err := resolveAuthType(cfg)
50+ require.NoError(t, err)
51+ assert.Equal(t, domain.AuthTypeToken, authType)
52+}
53+
54+func TestResolveAuthType_AcceptsEmpty(t *testing.T) {
55+ cfg := Config{}
56+ authType, err := resolveAuthType(cfg)
57+ require.NoError(t, err)
58+ assert.Equal(t, domain.AuthTypeToken, authType)
47 }59 }
48 60
49 func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {61 func TestLoadConfig_DoesNotDefaultSecretsBackendToUnsafe(t *testing.T) {
50 v := viper.New()62 v := viper.New()
51 63
52 err := loadConfig(v, "")64 err := loadConfig(v, "")
53- if err != nil {65+ require.NoError(t, err)
54- t.Fatalf("expected no error, got %v", err)66+ assert.Empty(t, v.GetString("auth.secrets_backend"), "expected empty default for auth.secrets_backend")
55- }
56-
57- if got := v.GetString("auth.secrets_backend"); got != "" {
58- t.Fatalf("expected empty default for auth.secrets_backend, got %q", got)
59- }
60 }67 }
M internal/boundaries/in/auth.go +3 -8
@@ -15,11 +15,6 @@ type AuthService interface {
1515 // IsEnabled returns whether authentication is enabled.
1616 IsEnabled() bool
1717
18- // Password authentication
19- // ValidatePassword checks if the username and password are valid.
20- ValidatePassword(ctx context.Context, username, password string) bool
21-
22- // Token authentication
2318 // ValidateToken validates a JWT token and returns its claims.
2419 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
2520
@@ -40,9 +35,6 @@ type AuthService interface {
4035 // ListTokens returns all stored tokens.
4136 ListTokens(ctx context.Context) ([]domain.Token, error)
4237
43- // GeneratePasswordHash generates a bcrypt hash for a password.
44- GeneratePasswordHash(password string) (string, error)
45-
4638 // GetAuthStatus returns authentication status from context.
4739 // Extracts and validates token claims that were set by auth middleware.
4840 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
@@ -51,4 +43,7 @@ type AuthService interface {
5143 // Returns the same token string if it was already extended within the debounce window (1h).
5244 // Skips extension for ephemeral access tokens (≤5min) and service tokens.
5345 ExtendToken(ctx context.Context, tokenString string) (string, error)
46+
47+ // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
48+ GetAccessTokenTTL() time.Duration
5449 }
@@ -15,11 +15,6 @@ type AuthService interface {
15 // IsEnabled returns whether authentication is enabled.15 // IsEnabled returns whether authentication is enabled.
16 IsEnabled() bool16 IsEnabled() bool
17 17
18- // Password authentication
19- // ValidatePassword checks if the username and password are valid.
20- ValidatePassword(ctx context.Context, username, password string) bool
21-
22- // Token authentication
23 // ValidateToken validates a JWT token and returns its claims.18 // ValidateToken validates a JWT token and returns its claims.
24 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)19 ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
25 20
@@ -40,9 +35,6 @@ type AuthService interface {
40 // ListTokens returns all stored tokens.35 // ListTokens returns all stored tokens.
41 ListTokens(ctx context.Context) ([]domain.Token, error)36 ListTokens(ctx context.Context) ([]domain.Token, error)
42 37
43- // GeneratePasswordHash generates a bcrypt hash for a password.
44- GeneratePasswordHash(password string) (string, error)
45-
46 // GetAuthStatus returns authentication status from context.38 // GetAuthStatus returns authentication status from context.
47 // Extracts and validates token claims that were set by auth middleware.39 // Extracts and validates token claims that were set by auth middleware.
48 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)40 GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error)
@@ -51,4 +43,7 @@ type AuthService interface {
51 // Returns the same token string if it was already extended within the debounce window (1h).43 // Returns the same token string if it was already extended within the debounce window (1h).
52 // Skips extension for ephemeral access tokens (≤5min) and service tokens.44 // Skips extension for ephemeral access tokens (≤5min) and service tokens.
53 ExtendToken(ctx context.Context, tokenString string) (string, error)45 ExtendToken(ctx context.Context, tokenString string) (string, error)
46+
47+ // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
48+ GetAccessTokenTTL() time.Duration
54 }49 }
M internal/boundaries/in/mocks/mock_auth_service.go +44 -123
@@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co
183183 return _c
184184 }
185185
186-// GeneratePasswordHash provides a mock function for the type MockAuthService
187-func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
188- ret := _mock.Called(password)
189-
190- if len(ret) == 0 {
191- panic("no return value specified for GeneratePasswordHash")
192- }
193-
194- var r0 string
195- var r1 error
196- if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
197- return returnFunc(password)
198- }
199- if returnFunc, ok := ret.Get(0).(func(string) string); ok {
200- r0 = returnFunc(password)
201- } else {
202- r0 = ret.Get(0).(string)
203- }
204- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
205- r1 = returnFunc(password)
206- } else {
207- r1 = ret.Error(1)
208- }
209- return r0, r1
210-}
211-
212-// MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash'
213-type MockAuthService_GeneratePasswordHash_Call struct {
214- *mock.Call
215-}
216-
217-// GeneratePasswordHash is a helper method to define mock.On call
218-// - password string
219-func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call {
220- return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)}
221-}
222-
223-func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call {
224- _c.Call.Run(func(args mock.Arguments) {
225- var arg0 string
226- if args[0] != nil {
227- arg0 = args[0].(string)
228- }
229- run(
230- arg0,
231- )
232- })
233- return _c
234-}
235-
236-func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call {
237- _c.Call.Return(s, err)
238- return _c
239-}
240-
241-func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call {
242- _c.Call.Return(run)
243- return _c
244-}
245-
246186 // GenerateToken provides a mock function for the type MockAuthService
247187 func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
248188 ret := _mock.Called(ctx, subject, scopes, expiry)
@@ -321,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
321261 return _c
322262 }
323263
264+// GetAccessTokenTTL provides a mock function for the type MockAuthService
265+func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration {
266+ ret := _mock.Called()
267+
268+ if len(ret) == 0 {
269+ panic("no return value specified for GetAccessTokenTTL")
270+ }
271+
272+ var r0 time.Duration
273+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
274+ r0 = returnFunc()
275+ } else {
276+ r0 = ret.Get(0).(time.Duration)
277+ }
278+ return r0
279+}
280+
281+// MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL'
282+type MockAuthService_GetAccessTokenTTL_Call struct {
283+ *mock.Call
284+}
285+
286+// GetAccessTokenTTL is a helper method to define mock.On call
287+func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call {
288+ return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")}
289+}
290+
291+func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call {
292+ _c.Call.Run(func(args mock.Arguments) {
293+ run()
294+ })
295+ return _c
296+}
297+
298+func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
299+ _c.Call.Return(duration)
300+ return _c
301+}
302+
303+func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
304+ _c.Call.Return(run)
305+ return _c
306+}
307+
324308 // GetAuthStatus provides a mock function for the type MockAuthService
325309 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
326310 ret := _mock.Called(ctx)
@@ -650,69 +634,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co
650634 return _c
651635 }
652636
653-// ValidatePassword provides a mock function for the type MockAuthService
654-func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool {
655- ret := _mock.Called(ctx, username, password)
656-
657- if len(ret) == 0 {
658- panic("no return value specified for ValidatePassword")
659- }
660-
661- var r0 bool
662- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
663- r0 = returnFunc(ctx, username, password)
664- } else {
665- r0 = ret.Get(0).(bool)
666- }
667- return r0
668-}
669-
670-// MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword'
671-type MockAuthService_ValidatePassword_Call struct {
672- *mock.Call
673-}
674-
675-// ValidatePassword is a helper method to define mock.On call
676-// - ctx context.Context
677-// - username string
678-// - password string
679-func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call {
680- return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)}
681-}
682-
683-func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call {
684- _c.Call.Run(func(args mock.Arguments) {
685- var arg0 context.Context
686- if args[0] != nil {
687- arg0 = args[0].(context.Context)
688- }
689- var arg1 string
690- if args[1] != nil {
691- arg1 = args[1].(string)
692- }
693- var arg2 string
694- if args[2] != nil {
695- arg2 = args[2].(string)
696- }
697- run(
698- arg0,
699- arg1,
700- arg2,
701- )
702- })
703- return _c
704-}
705-
706-func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call {
707- _c.Call.Return(b)
708- return _c
709-}
710-
711-func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call {
712- _c.Call.Return(run)
713- return _c
714-}
715-
716637 // ValidateToken provides a mock function for the type MockAuthService
717638 func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
718639 ret := _mock.Called(ctx, tokenString)
@@ -183,66 +183,6 @@ func (_c *MockAuthService_GenerateAccessToken_Call) RunAndReturn(run func(ctx co
183 return _c183 return _c
184 }184 }
185 185
186-// GeneratePasswordHash provides a mock function for the type MockAuthService
187-func (_mock *MockAuthService) GeneratePasswordHash(password string) (string, error) {
188- ret := _mock.Called(password)
189-
190- if len(ret) == 0 {
191- panic("no return value specified for GeneratePasswordHash")
192- }
193-
194- var r0 string
195- var r1 error
196- if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok {
197- return returnFunc(password)
198- }
199- if returnFunc, ok := ret.Get(0).(func(string) string); ok {
200- r0 = returnFunc(password)
201- } else {
202- r0 = ret.Get(0).(string)
203- }
204- if returnFunc, ok := ret.Get(1).(func(string) error); ok {
205- r1 = returnFunc(password)
206- } else {
207- r1 = ret.Error(1)
208- }
209- return r0, r1
210-}
211-
212-// MockAuthService_GeneratePasswordHash_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GeneratePasswordHash'
213-type MockAuthService_GeneratePasswordHash_Call struct {
214- *mock.Call
215-}
216-
217-// GeneratePasswordHash is a helper method to define mock.On call
218-// - password string
219-func (_e *MockAuthService_Expecter) GeneratePasswordHash(password interface{}) *MockAuthService_GeneratePasswordHash_Call {
220- return &MockAuthService_GeneratePasswordHash_Call{Call: _e.mock.On("GeneratePasswordHash", password)}
221-}
222-
223-func (_c *MockAuthService_GeneratePasswordHash_Call) Run(run func(password string)) *MockAuthService_GeneratePasswordHash_Call {
224- _c.Call.Run(func(args mock.Arguments) {
225- var arg0 string
226- if args[0] != nil {
227- arg0 = args[0].(string)
228- }
229- run(
230- arg0,
231- )
232- })
233- return _c
234-}
235-
236-func (_c *MockAuthService_GeneratePasswordHash_Call) Return(s string, err error) *MockAuthService_GeneratePasswordHash_Call {
237- _c.Call.Return(s, err)
238- return _c
239-}
240-
241-func (_c *MockAuthService_GeneratePasswordHash_Call) RunAndReturn(run func(password string) (string, error)) *MockAuthService_GeneratePasswordHash_Call {
242- _c.Call.Return(run)
243- return _c
244-}
245-
246 // GenerateToken provides a mock function for the type MockAuthService186 // GenerateToken provides a mock function for the type MockAuthService
247 func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {187 func (_mock *MockAuthService) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
248 ret := _mock.Called(ctx, subject, scopes, expiry)188 ret := _mock.Called(ctx, subject, scopes, expiry)
@@ -321,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
321 return _c261 return _c
322 }262 }
323 263
264+// GetAccessTokenTTL provides a mock function for the type MockAuthService
265+func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration {
266+ ret := _mock.Called()
267+
268+ if len(ret) == 0 {
269+ panic("no return value specified for GetAccessTokenTTL")
270+ }
271+
272+ var r0 time.Duration
273+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
274+ r0 = returnFunc()
275+ } else {
276+ r0 = ret.Get(0).(time.Duration)
277+ }
278+ return r0
279+}
280+
281+// MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL'
282+type MockAuthService_GetAccessTokenTTL_Call struct {
283+ *mock.Call
284+}
285+
286+// GetAccessTokenTTL is a helper method to define mock.On call
287+func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call {
288+ return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")}
289+}
290+
291+func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call {
292+ _c.Call.Run(func(args mock.Arguments) {
293+ run()
294+ })
295+ return _c
296+}
297+
298+func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
299+ _c.Call.Return(duration)
300+ return _c
301+}
302+
303+func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
304+ _c.Call.Return(run)
305+ return _c
306+}
307+
324 // GetAuthStatus provides a mock function for the type MockAuthService308 // GetAuthStatus provides a mock function for the type MockAuthService
325 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {309 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
326 ret := _mock.Called(ctx)310 ret := _mock.Called(ctx)
@@ -650,69 +634,6 @@ func (_c *MockAuthService_RevokeToken_Call) RunAndReturn(run func(ctx context.Co
650 return _c634 return _c
651 }635 }
652 636
653-// ValidatePassword provides a mock function for the type MockAuthService
654-func (_mock *MockAuthService) ValidatePassword(ctx context.Context, username string, password string) bool {
655- ret := _mock.Called(ctx, username, password)
656-
657- if len(ret) == 0 {
658- panic("no return value specified for ValidatePassword")
659- }
660-
661- var r0 bool
662- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
663- r0 = returnFunc(ctx, username, password)
664- } else {
665- r0 = ret.Get(0).(bool)
666- }
667- return r0
668-}
669-
670-// MockAuthService_ValidatePassword_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidatePassword'
671-type MockAuthService_ValidatePassword_Call struct {
672- *mock.Call
673-}
674-
675-// ValidatePassword is a helper method to define mock.On call
676-// - ctx context.Context
677-// - username string
678-// - password string
679-func (_e *MockAuthService_Expecter) ValidatePassword(ctx interface{}, username interface{}, password interface{}) *MockAuthService_ValidatePassword_Call {
680- return &MockAuthService_ValidatePassword_Call{Call: _e.mock.On("ValidatePassword", ctx, username, password)}
681-}
682-
683-func (_c *MockAuthService_ValidatePassword_Call) Run(run func(ctx context.Context, username string, password string)) *MockAuthService_ValidatePassword_Call {
684- _c.Call.Run(func(args mock.Arguments) {
685- var arg0 context.Context
686- if args[0] != nil {
687- arg0 = args[0].(context.Context)
688- }
689- var arg1 string
690- if args[1] != nil {
691- arg1 = args[1].(string)
692- }
693- var arg2 string
694- if args[2] != nil {
695- arg2 = args[2].(string)
696- }
697- run(
698- arg0,
699- arg1,
700- arg2,
701- )
702- })
703- return _c
704-}
705-
706-func (_c *MockAuthService_ValidatePassword_Call) Return(b bool) *MockAuthService_ValidatePassword_Call {
707- _c.Call.Return(b)
708- return _c
709-}
710-
711-func (_c *MockAuthService_ValidatePassword_Call) RunAndReturn(run func(ctx context.Context, username string, password string) bool) *MockAuthService_ValidatePassword_Call {
712- _c.Call.Return(run)
713- return _c
714-}
715-
716 // ValidateToken provides a mock function for the type MockAuthService637 // ValidateToken provides a mock function for the type MockAuthService
717 func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {638 func (_mock *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
718 ret := _mock.Called(ctx, tokenString)639 ret := _mock.Called(ctx, tokenString)
M internal/domain/auth.go +40 -6
@@ -12,8 +12,6 @@ import (
1212 type AuthType string
1313
1414 const (
15- // AuthTypePassword uses bcrypt-hashed password authentication.
16- AuthTypePassword AuthType = "password"
1715 // AuthTypeToken uses JWT token-based authentication.
1816 AuthTypeToken AuthType = "token"
1917 )
@@ -220,21 +218,57 @@ func (s *AdminScope) String() string {
220218
221219 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.
222220 func HasAdminAccess(scopes []string, resource, action string) bool {
223- for _, scopeStr := range scopes {
221+ return ScopesGrantAdminAccess(scopes, resource, action)
222+}
223+
224+// ScopesGrantRegistryAccess reports whether any of the granted scope strings
225+// authorise the given action on the named repository.
226+// It handles simple shorthand scopes ("pull", "push", "*") for backwards
227+// compatibility as well as full Docker v2 format scopes (repository:name:actions).
228+func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool {
229+ for _, raw := range grantedScopes {
230+ scopeStr := strings.TrimSpace(raw)
231+
232+ // Shorthand scopes: "*", "pull", "push"
233+ switch scopeStr {
234+ case ScopeActionAll:
235+ return true
236+ case ScopeActionPull, ScopeActionPush:
237+ if scopeStr == action {
238+ return true
239+ }
240+ }
241+
242+ // Full Docker v2 format: repository:name:actions
224243 scope, err := ParseScope(scopeStr)
225244 if err != nil {
226245 continue
227246 }
228-
229- if !scope.IsAdminScope() {
247+ if scope.Type != ScopeTypeRepository {
230248 continue
231249 }
250+ if scope.CanAccess(repoName, action) {
251+ return true
252+ }
253+ }
254+ return false
255+}
256+
257+// ScopesGrantAdminAccess reports whether any of the granted scope strings
258+// authorise the given action on the named admin resource.
259+// It handles the wildcard shorthand ("*") as well as full admin scope format
260+// (admin:resource:actions).
261+func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
262+ for _, raw := range grantedScopes {
263+ scopeStr := strings.TrimSpace(raw)
264+ if scopeStr == ScopeActionAll {
265+ return true
266+ }
232267
233268 adminScope, err := ParseAdminScope(scopeStr)
234269 if err != nil {
235270 continue
236271 }
237-
238272 if adminScope.CanAccess(resource, action) {
239273 return true
240274 }
@@ -12,8 +12,6 @@ import (
12 type AuthType string12 type AuthType string
13 13
14 const (14 const (
15- // AuthTypePassword uses bcrypt-hashed password authentication.
16- AuthTypePassword AuthType = "password"
17 // AuthTypeToken uses JWT token-based authentication.15 // AuthTypeToken uses JWT token-based authentication.
18 AuthTypeToken AuthType = "token"16 AuthTypeToken AuthType = "token"
19 )17 )
@@ -220,21 +218,57 @@ func (s *AdminScope) String() string {
220 218
221 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.219 // HasAdminAccess checks if a list of scope strings grants admin access to the resource and action.
222 func HasAdminAccess(scopes []string, resource, action string) bool {220 func HasAdminAccess(scopes []string, resource, action string) bool {
223- for _, scopeStr := range scopes {221+ return ScopesGrantAdminAccess(scopes, resource, action)
222+}
223+
224+// ScopesGrantRegistryAccess reports whether any of the granted scope strings
225+// authorise the given action on the named repository.
226+// It handles simple shorthand scopes ("pull", "push", "*") for backwards
227+// compatibility as well as full Docker v2 format scopes (repository:name:actions).
228+func ScopesGrantRegistryAccess(grantedScopes []string, repoName, action string) bool {
229+ for _, raw := range grantedScopes {
230+ scopeStr := strings.TrimSpace(raw)
231+
232+ // Shorthand scopes: "*", "pull", "push"
233+ switch scopeStr {
234+ case ScopeActionAll:
235+ return true
236+ case ScopeActionPull, ScopeActionPush:
237+ if scopeStr == action {
238+ return true
239+ }
240+ }
241+
242+ // Full Docker v2 format: repository:name:actions
224 scope, err := ParseScope(scopeStr)243 scope, err := ParseScope(scopeStr)
225 if err != nil {244 if err != nil {
226 continue245 continue
227 }246 }
228-247+ if scope.Type != ScopeTypeRepository {
229- if !scope.IsAdminScope() {
230 continue248 continue
231 }249 }
250+ if scope.CanAccess(repoName, action) {
251+ return true
252+ }
253+ }
254+ return false
255+}
256+
257+// ScopesGrantAdminAccess reports whether any of the granted scope strings
258+// authorise the given action on the named admin resource.
259+// It handles the wildcard shorthand ("*") as well as full admin scope format
260+// (admin:resource:actions).
261+func ScopesGrantAdminAccess(grantedScopes []string, resource, action string) bool {
262+ for _, raw := range grantedScopes {
263+ scopeStr := strings.TrimSpace(raw)
264+ if scopeStr == ScopeActionAll {
265+ return true
266+ }
232 267
233 adminScope, err := ParseAdminScope(scopeStr)268 adminScope, err := ParseAdminScope(scopeStr)
234 if err != nil {269 if err != nil {
235 continue270 continue
236 }271 }
237-
238 if adminScope.CanAccess(resource, action) {272 if adminScope.CanAccess(resource, action) {
239 return true273 return true
240 }274 }
M internal/domain/auth_test.go +197 -0
@@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) {
325325 }
326326 }
327327
328+func TestScopesGrantRegistryAccess(t *testing.T) {
329+ tests := []struct {
330+ name string
331+ granted []string
332+ repoName string
333+ action string
334+ want bool
335+ }{
336+ {
337+ name: "wildcard shorthand grants any action",
338+ granted: []string{"*"},
339+ repoName: "myrepo",
340+ action: "push",
341+ want: true,
342+ },
343+ {
344+ name: "pull shorthand grants pull",
345+ granted: []string{"pull"},
346+ repoName: "myrepo",
347+ action: "pull",
348+ want: true,
349+ },
350+ {
351+ name: "pull shorthand does not grant push",
352+ granted: []string{"pull"},
353+ repoName: "myrepo",
354+ action: "push",
355+ want: false,
356+ },
357+ {
358+ name: "push shorthand grants push",
359+ granted: []string{"push"},
360+ repoName: "myrepo",
361+ action: "push",
362+ want: true,
363+ },
364+ {
365+ name: "full scope grants matching repo and action",
366+ granted: []string{"repository:myrepo:push,pull"},
367+ repoName: "myrepo",
368+ action: "pull",
369+ want: true,
370+ },
371+ {
372+ name: "full scope does not grant different repo",
373+ granted: []string{"repository:myrepo:push,pull"},
374+ repoName: "other",
375+ action: "pull",
376+ want: false,
377+ },
378+ {
379+ name: "wildcard repo scope grants any repo",
380+ granted: []string{"repository:*:pull"},
381+ repoName: "anything",
382+ action: "pull",
383+ want: true,
384+ },
385+ {
386+ name: "org wildcard scope grants repo in org",
387+ granted: []string{"repository:myorg/*:push"},
388+ repoName: "myorg/app",
389+ action: "push",
390+ want: true,
391+ },
392+ {
393+ name: "org wildcard scope rejects repo outside org",
394+ granted: []string{"repository:myorg/*:push"},
395+ repoName: "other/app",
396+ action: "push",
397+ want: false,
398+ },
399+ {
400+ name: "admin scope is ignored for registry access",
401+ granted: []string{"admin:routes:read"},
402+ repoName: "myrepo",
403+ action: "pull",
404+ want: false,
405+ },
406+ {
407+ name: "no granted scopes",
408+ granted: []string{},
409+ repoName: "myrepo",
410+ action: "pull",
411+ want: false,
412+ },
413+ {
414+ name: "whitespace around scope is trimmed",
415+ granted: []string{" repository:myrepo:pull "},
416+ repoName: "myrepo",
417+ action: "pull",
418+ want: true,
419+ },
420+ {
421+ name: "invalid scope string is skipped",
422+ granted: []string{"garbage", "repository:myrepo:pull"},
423+ repoName: "myrepo",
424+ action: "pull",
425+ want: true,
426+ },
427+ }
428+
429+ for _, tt := range tests {
430+ t.Run(tt.name, func(t *testing.T) {
431+ got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action)
432+ assert.Equal(t, tt.want, got)
433+ })
434+ }
435+}
436+
437+func TestScopesGrantAdminAccess(t *testing.T) {
438+ tests := []struct {
439+ name string
440+ granted []string
441+ resource string
442+ action string
443+ want bool
444+ }{
445+ {
446+ name: "wildcard shorthand grants any admin access",
447+ granted: []string{"*"},
448+ resource: "routes",
449+ action: "read",
450+ want: true,
451+ },
452+ {
453+ name: "exact admin scope grants matching resource and action",
454+ granted: []string{"admin:routes:read"},
455+ resource: "routes",
456+ action: "read",
457+ want: true,
458+ },
459+ {
460+ name: "exact admin scope denies wrong action",
461+ granted: []string{"admin:routes:read"},
462+ resource: "routes",
463+ action: "write",
464+ want: false,
465+ },
466+ {
467+ name: "exact admin scope denies wrong resource",
468+ granted: []string{"admin:routes:read"},
469+ resource: "secrets",
470+ action: "read",
471+ want: false,
472+ },
473+ {
474+ name: "admin wildcard resource grants any resource",
475+ granted: []string{"admin:*:read"},
476+ resource: "secrets",
477+ action: "read",
478+ want: true,
479+ },
480+ {
481+ name: "admin wildcard action grants any action",
482+ granted: []string{"admin:routes:*"},
483+ resource: "routes",
484+ action: "write",
485+ want: true,
486+ },
487+ {
488+ name: "full admin wildcard grants everything",
489+ granted: []string{"admin:*:*"},
490+ resource: "config",
491+ action: "write",
492+ want: true,
493+ },
494+ {
495+ name: "repository scope is ignored for admin access",
496+ granted: []string{"repository:myrepo:push"},
497+ resource: "routes",
498+ action: "read",
499+ want: false,
500+ },
501+ {
502+ name: "no granted scopes",
503+ granted: []string{},
504+ resource: "routes",
505+ action: "read",
506+ want: false,
507+ },
508+ {
509+ name: "whitespace around scope is trimmed",
510+ granted: []string{" admin:routes:read "},
511+ resource: "routes",
512+ action: "read",
513+ want: true,
514+ },
515+ }
516+
517+ for _, tt := range tests {
518+ t.Run(tt.name, func(t *testing.T) {
519+ got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action)
520+ assert.Equal(t, tt.want, got)
521+ })
522+ }
523+}
524+
328525 func TestParseScope_RoundTrip(t *testing.T) {
329526 // Test that parsing and stringifying produces the same result
330527 inputs := []string{
@@ -325,6 +325,203 @@ func TestScope_String(t *testing.T) {
325 }325 }
326 }326 }
327 327
328+func TestScopesGrantRegistryAccess(t *testing.T) {
329+ tests := []struct {
330+ name string
331+ granted []string
332+ repoName string
333+ action string
334+ want bool
335+ }{
336+ {
337+ name: "wildcard shorthand grants any action",
338+ granted: []string{"*"},
339+ repoName: "myrepo",
340+ action: "push",
341+ want: true,
342+ },
343+ {
344+ name: "pull shorthand grants pull",
345+ granted: []string{"pull"},
346+ repoName: "myrepo",
347+ action: "pull",
348+ want: true,
349+ },
350+ {
351+ name: "pull shorthand does not grant push",
352+ granted: []string{"pull"},
353+ repoName: "myrepo",
354+ action: "push",
355+ want: false,
356+ },
357+ {
358+ name: "push shorthand grants push",
359+ granted: []string{"push"},
360+ repoName: "myrepo",
361+ action: "push",
362+ want: true,
363+ },
364+ {
365+ name: "full scope grants matching repo and action",
366+ granted: []string{"repository:myrepo:push,pull"},
367+ repoName: "myrepo",
368+ action: "pull",
369+ want: true,
370+ },
371+ {
372+ name: "full scope does not grant different repo",
373+ granted: []string{"repository:myrepo:push,pull"},
374+ repoName: "other",
375+ action: "pull",
376+ want: false,
377+ },
378+ {
379+ name: "wildcard repo scope grants any repo",
380+ granted: []string{"repository:*:pull"},
381+ repoName: "anything",
382+ action: "pull",
383+ want: true,
384+ },
385+ {
386+ name: "org wildcard scope grants repo in org",
387+ granted: []string{"repository:myorg/*:push"},
388+ repoName: "myorg/app",
389+ action: "push",
390+ want: true,
391+ },
392+ {
393+ name: "org wildcard scope rejects repo outside org",
394+ granted: []string{"repository:myorg/*:push"},
395+ repoName: "other/app",
396+ action: "push",
397+ want: false,
398+ },
399+ {
400+ name: "admin scope is ignored for registry access",
401+ granted: []string{"admin:routes:read"},
402+ repoName: "myrepo",
403+ action: "pull",
404+ want: false,
405+ },
406+ {
407+ name: "no granted scopes",
408+ granted: []string{},
409+ repoName: "myrepo",
410+ action: "pull",
411+ want: false,
412+ },
413+ {
414+ name: "whitespace around scope is trimmed",
415+ granted: []string{" repository:myrepo:pull "},
416+ repoName: "myrepo",
417+ action: "pull",
418+ want: true,
419+ },
420+ {
421+ name: "invalid scope string is skipped",
422+ granted: []string{"garbage", "repository:myrepo:pull"},
423+ repoName: "myrepo",
424+ action: "pull",
425+ want: true,
426+ },
427+ }
428+
429+ for _, tt := range tests {
430+ t.Run(tt.name, func(t *testing.T) {
431+ got := ScopesGrantRegistryAccess(tt.granted, tt.repoName, tt.action)
432+ assert.Equal(t, tt.want, got)
433+ })
434+ }
435+}
436+
437+func TestScopesGrantAdminAccess(t *testing.T) {
438+ tests := []struct {
439+ name string
440+ granted []string
441+ resource string
442+ action string
443+ want bool
444+ }{
445+ {
446+ name: "wildcard shorthand grants any admin access",
447+ granted: []string{"*"},
448+ resource: "routes",
449+ action: "read",
450+ want: true,
451+ },
452+ {
453+ name: "exact admin scope grants matching resource and action",
454+ granted: []string{"admin:routes:read"},
455+ resource: "routes",
456+ action: "read",
457+ want: true,
458+ },
459+ {
460+ name: "exact admin scope denies wrong action",
461+ granted: []string{"admin:routes:read"},
462+ resource: "routes",
463+ action: "write",
464+ want: false,
465+ },
466+ {
467+ name: "exact admin scope denies wrong resource",
468+ granted: []string{"admin:routes:read"},
469+ resource: "secrets",
470+ action: "read",
471+ want: false,
472+ },
473+ {
474+ name: "admin wildcard resource grants any resource",
475+ granted: []string{"admin:*:read"},
476+ resource: "secrets",
477+ action: "read",
478+ want: true,
479+ },
480+ {
481+ name: "admin wildcard action grants any action",
482+ granted: []string{"admin:routes:*"},
483+ resource: "routes",
484+ action: "write",
485+ want: true,
486+ },
487+ {
488+ name: "full admin wildcard grants everything",
489+ granted: []string{"admin:*:*"},
490+ resource: "config",
491+ action: "write",
492+ want: true,
493+ },
494+ {
495+ name: "repository scope is ignored for admin access",
496+ granted: []string{"repository:myrepo:push"},
497+ resource: "routes",
498+ action: "read",
499+ want: false,
500+ },
501+ {
502+ name: "no granted scopes",
503+ granted: []string{},
504+ resource: "routes",
505+ action: "read",
506+ want: false,
507+ },
508+ {
509+ name: "whitespace around scope is trimmed",
510+ granted: []string{" admin:routes:read "},
511+ resource: "routes",
512+ action: "read",
513+ want: true,
514+ },
515+ }
516+
517+ for _, tt := range tests {
518+ t.Run(tt.name, func(t *testing.T) {
519+ got := ScopesGrantAdminAccess(tt.granted, tt.resource, tt.action)
520+ assert.Equal(t, tt.want, got)
521+ })
522+ }
523+}
524+
328 func TestParseScope_RoundTrip(t *testing.T) {525 func TestParseScope_RoundTrip(t *testing.T) {
329 // Test that parsing and stringifying produces the same result526 // Test that parsing and stringifying produces the same result
330 inputs := []string{527 inputs := []string{
M internal/domain/container.go +3 -0
@@ -58,6 +58,9 @@ type ContainerConfig struct {
5858 NetworkMode string // Network to join
5959 Hostname string // Container hostname for DNS
6060 Aliases []string // Additional network aliases
61+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
62+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
63+ PidsLimit int64 // Max number of PIDs (0 = no limit)
6164 }
6265
6366 // ContainerStatus represents the current state of a container.
@@ -58,6 +58,9 @@ type ContainerConfig struct {
58 NetworkMode string // Network to join58 NetworkMode string // Network to join
59 Hostname string // Container hostname for DNS59 Hostname string // Container hostname for DNS
60 Aliases []string // Additional network aliases60 Aliases []string // Additional network aliases
61+ MemoryLimit int64 // Memory limit in bytes (0 = no limit)
62+ NanoCPUs int64 // CPU quota in nanoseconds (1e9 = 1 core, 0 = no limit)
63+ PidsLimit int64 // Max number of PIDs (0 = no limit)
61 }64 }
62 65
63 // ContainerStatus represents the current state of a container.66 // ContainerStatus represents the current state of a container.
M internal/usecase/auth/service.go +14 -42
@@ -3,7 +3,6 @@ package auth
33
44 import (
55 "context"
6- "crypto/subtle"
76 "errors"
87 "fmt"
98 "time"
@@ -11,7 +10,6 @@ import (
1110 "github.com/bnema/zerowrap"
1211 "github.com/golang-jwt/jwt/v5"
1312 "github.com/google/uuid"
14- "golang.org/x/crypto/bcrypt"
1513
1614 "github.com/bnema/gordon/internal/boundaries/out"
1715 "github.com/bnema/gordon/internal/domain"
@@ -20,11 +18,11 @@ import (
2018 const (
2119 // TokenIssuer is the issuer claim for generated tokens.
2220 TokenIssuer = "gordon-registry"
23- // DefaultBcryptCost is the default cost for bcrypt hashing.
24- DefaultBcryptCost = 12
21+ // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens.
22+ DefaultAccessTokenTTL = 15 * time.Minute
2523 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
2624 // Tokens with this lifetime or less skip store validation.
27- MaxAccessTokenLifetime = 5 * time.Minute
25+ MaxAccessTokenLifetime = 1 * time.Hour
2826 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
2927 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
3028 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
@@ -38,12 +36,12 @@ const (
3836
3937 // Config holds the authentication configuration.
4038 type Config struct {
41- Enabled bool
42- AuthType domain.AuthType
43- Username string
44- PasswordHash string // bcrypt hash for password auth
45- TokenSecret []byte // signing secret for token auth
46- TokenExpiry time.Duration // default token expiry (0 = never)
39+ Enabled bool
40+ AuthType domain.AuthType
41+ Username string
42+ TokenSecret []byte // signing secret for token auth
43+ TokenExpiry time.Duration // default token expiry (0 = never)
44+ AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m)
4745 }
4846
4947 // Service implements the AuthService interface.
@@ -72,29 +70,12 @@ func (s *Service) IsEnabled() bool {
7270 return s.config.Enabled
7371 }
7472
75-// ValidatePassword checks if the username and password are valid.
76-func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
77- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
78- zerowrap.FieldLayer: "usecase",
79- zerowrap.FieldUseCase: "ValidatePassword",
80- "username": username,
81- })
82- log := zerowrap.FromCtx(ctx)
83-
84- // Constant-time username comparison
85- usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
86-
87- // Bcrypt comparison (already constant-time)
88- err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
89- passwordMatch := err == nil
90-
91- if !usernameMatch || !passwordMatch {
92- log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
93- return false
73+// GetAccessTokenTTL returns the configured ephemeral access token lifetime.
74+func (s *Service) GetAccessTokenTTL() time.Duration {
75+ if s.config.AccessTokenTTL > 0 {
76+ return s.config.AccessTokenTTL
9477 }
95-
96- log.Debug().Msg("password validation successful")
97- return true
78+ return DefaultAccessTokenTTL
9879 }
9980
10081 // ValidateToken validates a JWT token and returns its claims.
@@ -560,15 +541,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
560541 return newTokenString, nil
561542 }
562543
563-// GeneratePasswordHash generates a bcrypt hash for a password.
564-func (s *Service) GeneratePasswordHash(password string) (string, error) {
565- hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
566- if err != nil {
567- return "", err
568- }
569- return string(hash), nil
570-}
571-
572544 // getStringClaim safely extracts a string claim from JWT claims.
573545 func getStringClaim(claims jwt.MapClaims, key string) string {
574546 if val, ok := claims[key].(string); ok {
@@ -3,7 +3,6 @@ package auth
3 3
4 import (4 import (
5 "context"5 "context"
6- "crypto/subtle"
7 "errors"6 "errors"
8 "fmt"7 "fmt"
9 "time"8 "time"
@@ -11,7 +10,6 @@ import (
11 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
12 "github.com/golang-jwt/jwt/v5"11 "github.com/golang-jwt/jwt/v5"
13 "github.com/google/uuid"12 "github.com/google/uuid"
14- "golang.org/x/crypto/bcrypt"
15 13
16 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
@@ -20,11 +18,11 @@ import (
20 const (18 const (
21 // TokenIssuer is the issuer claim for generated tokens.19 // TokenIssuer is the issuer claim for generated tokens.
22 TokenIssuer = "gordon-registry"20 TokenIssuer = "gordon-registry"
23- // DefaultBcryptCost is the default cost for bcrypt hashing.21+ // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens.
24- DefaultBcryptCost = 1222+ DefaultAccessTokenTTL = 15 * time.Minute
25 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.23 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
26 // Tokens with this lifetime or less skip store validation.24 // Tokens with this lifetime or less skip store validation.
27- MaxAccessTokenLifetime = 5 * time.Minute25+ MaxAccessTokenLifetime = 1 * time.Hour
28 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.26 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
29 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)27 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
30 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.28 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
@@ -38,12 +36,12 @@ const (
38 36
39 // Config holds the authentication configuration.37 // Config holds the authentication configuration.
40 type Config struct {38 type Config struct {
41- Enabled bool39+ Enabled bool
42- AuthType domain.AuthType40+ AuthType domain.AuthType
43- Username string41+ Username string
44- PasswordHash string // bcrypt hash for password auth42+ TokenSecret []byte // signing secret for token auth
45- TokenSecret []byte // signing secret for token auth43+ TokenExpiry time.Duration // default token expiry (0 = never)
46- TokenExpiry time.Duration // default token expiry (0 = never)44+ AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m)
47 }45 }
48 46
49 // Service implements the AuthService interface.47 // Service implements the AuthService interface.
@@ -72,29 +70,12 @@ func (s *Service) IsEnabled() bool {
72 return s.config.Enabled70 return s.config.Enabled
73 }71 }
74 72
75-// ValidatePassword checks if the username and password are valid.73+// GetAccessTokenTTL returns the configured ephemeral access token lifetime.
76-func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {74+func (s *Service) GetAccessTokenTTL() time.Duration {
77- ctx = zerowrap.CtxWithFields(ctx, map[string]any{75+ if s.config.AccessTokenTTL > 0 {
78- zerowrap.FieldLayer: "usecase",76+ return s.config.AccessTokenTTL
79- zerowrap.FieldUseCase: "ValidatePassword",
80- "username": username,
81- })
82- log := zerowrap.FromCtx(ctx)
83-
84- // Constant-time username comparison
85- usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
86-
87- // Bcrypt comparison (already constant-time)
88- err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
89- passwordMatch := err == nil
90-
91- if !usernameMatch || !passwordMatch {
92- log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
93- return false
94 }77 }
95-78+ return DefaultAccessTokenTTL
96- log.Debug().Msg("password validation successful")
97- return true
98 }79 }
99 80
100 // ValidateToken validates a JWT token and returns its claims.81 // ValidateToken validates a JWT token and returns its claims.
@@ -560,15 +541,6 @@ func (s *Service) ExtendToken(ctx context.Context, tokenString string) (string,
560 return newTokenString, nil541 return newTokenString, nil
561 }542 }
562 543
563-// GeneratePasswordHash generates a bcrypt hash for a password.
564-func (s *Service) GeneratePasswordHash(password string) (string, error) {
565- hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
566- if err != nil {
567- return "", err
568- }
569- return string(hash), nil
570-}
571-
572 // getStringClaim safely extracts a string claim from JWT claims.544 // getStringClaim safely extracts a string claim from JWT claims.
573 func getStringClaim(claims jwt.MapClaims, key string) string {545 func getStringClaim(claims jwt.MapClaims, key string) string {
574 if val, ok := claims[key].(string); ok {546 if val, ok := claims[key].(string); ok {
M internal/usecase/auth/service_test.go +9 -95
@@ -11,7 +11,6 @@ import (
1111 "github.com/stretchr/testify/assert"
1212 "github.com/stretchr/testify/mock"
1313 "github.com/stretchr/testify/require"
14- "golang.org/x/crypto/bcrypt"
1514
1615 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1716 "github.com/bnema/gordon/internal/domain"
@@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) {
2625 name string
2726 authType domain.AuthType
2827 }{
29- {"password type", domain.AuthTypePassword},
3028 {"token type", domain.AuthTypeToken},
3129 }
3230
@@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) {
5553 }
5654 }
5755
58-func TestService_ValidatePassword_Success(t *testing.T) {
59- password := "testpassword123"
60- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
61- require.NoError(t, err)
62-
63- svc := NewService(Config{
64- Enabled: true,
65- AuthType: domain.AuthTypePassword,
66- Username: "testuser",
67- PasswordHash: string(hash),
68- }, nil, zerowrap.Default())
69-
70- ctx := testContext()
71- assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
72-}
73-
74-func TestService_ValidatePassword_WrongPassword(t *testing.T) {
75- hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
76- require.NoError(t, err)
77-
78- svc := NewService(Config{
79- Enabled: true,
80- AuthType: domain.AuthTypePassword,
81- Username: "testuser",
82- PasswordHash: string(hash),
83- }, nil, zerowrap.Default())
84-
85- ctx := testContext()
86- assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
87-}
88-
89-func TestService_ValidatePassword_WrongUsername(t *testing.T) {
90- password := "testpassword123"
91- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
92- require.NoError(t, err)
93-
94- svc := NewService(Config{
95- Enabled: true,
96- AuthType: domain.AuthTypePassword,
97- Username: "testuser",
98- PasswordHash: string(hash),
99- }, nil, zerowrap.Default())
100-
101- ctx := testContext()
102- assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
103-}
104-
10556 func TestService_GenerateToken_Success(t *testing.T) {
10657 tokenStore := mocks.NewMockTokenStore(t)
10758
@@ -229,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
229180 assert.Contains(t, err.Error(), "expiry must be positive")
230181
231182 // Expiry exceeding max should be rejected
232- _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
183+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, MaxAccessTokenLifetime+time.Minute)
233184 assert.Error(t, err)
234185 assert.Contains(t, err.Error(), "exceeds maximum")
235186 }
236187
237188 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
238- // Tokens with expiry > 5 minutes must exist in store
189+ // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store
239190 tokenStore := mocks.NewMockTokenStore(t)
240191
241192 svc := NewService(Config{
@@ -255,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
255206 }).
256207 Return(nil)
257208
258- token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
209+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
259210 assert.NoError(t, err)
260211
261212 // Validation should call GetToken and IsRevoked
@@ -332,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) {
332283 Return(nil)
333284
334285 ctx := testContext()
335- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
286+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
336287 require.NoError(t, err)
337288
338289 // Expect GetToken to be called to verify token exists
@@ -390,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) {
390341 Return(nil)
391342
392343 ctx := testContext()
393- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
344+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
394345 require.NoError(t, err)
395346
396347 // Expect GetToken to be called to verify token exists
@@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) {
720671 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
721672 }
722673
723-func TestService_GeneratePasswordHash(t *testing.T) {
724- svc := NewService(Config{}, nil, zerowrap.Default())
725-
726- password := "securePassword123!"
727- hash, err := svc.GeneratePasswordHash(password)
728-
729- assert.NoError(t, err)
730- assert.NotEmpty(t, hash)
731- assert.NotEqual(t, password, hash, "hash should not equal plain password")
732-
733- // Verify the hash works with bcrypt
734- err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
735- assert.NoError(t, err)
736-
737- // Verify wrong password fails
738- err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
739- assert.Error(t, err)
740-}
741-
742-func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
743- svc := NewService(Config{}, nil, zerowrap.Default())
744-
745- password := "testpassword"
746- hash1, err := svc.GeneratePasswordHash(password)
747- require.NoError(t, err)
748-
749- hash2, err := svc.GeneratePasswordHash(password)
750- require.NoError(t, err)
751-
752- // Bcrypt should generate different hashes each time due to salt
753- assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
754-
755- // But both should validate correctly
756- assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
757- assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
758-}
759-
760674 func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
761675 // Test that generated tokens include the nbf (not-before) claim
762676 // This is a security requirement per SEC-MED-006
@@ -829,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
829743
830744 ctx := testContext()
831745
832- // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
746+ // Generate a token with a short remaining life (MaxAccessTokenLifetime+1m) so ExtendToken produces a different exp
833747 var capturedToken *domain.Token
834748 tokenStore.EXPECT().
835749 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -838,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
838752 }).
839753 Return(nil)
840754
841- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
755+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute)
842756 require.NoError(t, err)
843757
844758 // ExtendToken flow:
@@ -901,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) {
901815
902816 ctx := testContext()
903817
904- // Generate a short-lived token (1h) so the first extension produces a different JWT
818+ // Generate a short-lived token (MaxAccessTokenLifetime+1m) so the first extension produces a different JWT
905819 var capturedToken *domain.Token
906820 tokenStore.EXPECT().
907821 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -910,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) {
910824 }).
911825 Return(nil)
912826
913- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
827+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute)
914828 require.NoError(t, err)
915829
916830 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
@@ -11,7 +11,6 @@ import (
11 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14- "golang.org/x/crypto/bcrypt"
15 14
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"15 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
@@ -26,7 +25,6 @@ func TestService_GetAuthType(t *testing.T) {
26 name string25 name string
27 authType domain.AuthType26 authType domain.AuthType
28 }{27 }{
29- {"password type", domain.AuthTypePassword},
30 {"token type", domain.AuthTypeToken},28 {"token type", domain.AuthTypeToken},
31 }29 }
32 30
@@ -55,53 +53,6 @@ func TestService_IsEnabled(t *testing.T) {
55 }53 }
56 }54 }
57 55
58-func TestService_ValidatePassword_Success(t *testing.T) {
59- password := "testpassword123"
60- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
61- require.NoError(t, err)
62-
63- svc := NewService(Config{
64- Enabled: true,
65- AuthType: domain.AuthTypePassword,
66- Username: "testuser",
67- PasswordHash: string(hash),
68- }, nil, zerowrap.Default())
69-
70- ctx := testContext()
71- assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
72-}
73-
74-func TestService_ValidatePassword_WrongPassword(t *testing.T) {
75- hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
76- require.NoError(t, err)
77-
78- svc := NewService(Config{
79- Enabled: true,
80- AuthType: domain.AuthTypePassword,
81- Username: "testuser",
82- PasswordHash: string(hash),
83- }, nil, zerowrap.Default())
84-
85- ctx := testContext()
86- assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
87-}
88-
89-func TestService_ValidatePassword_WrongUsername(t *testing.T) {
90- password := "testpassword123"
91- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
92- require.NoError(t, err)
93-
94- svc := NewService(Config{
95- Enabled: true,
96- AuthType: domain.AuthTypePassword,
97- Username: "testuser",
98- PasswordHash: string(hash),
99- }, nil, zerowrap.Default())
100-
101- ctx := testContext()
102- assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
103-}
104-
105 func TestService_GenerateToken_Success(t *testing.T) {56 func TestService_GenerateToken_Success(t *testing.T) {
106 tokenStore := mocks.NewMockTokenStore(t)57 tokenStore := mocks.NewMockTokenStore(t)
107 58
@@ -229,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
229 assert.Contains(t, err.Error(), "expiry must be positive")180 assert.Contains(t, err.Error(), "expiry must be positive")
230 181
231 // Expiry exceeding max should be rejected182 // Expiry exceeding max should be rejected
232- _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)183+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, MaxAccessTokenLifetime+time.Minute)
233 assert.Error(t, err)184 assert.Error(t, err)
234 assert.Contains(t, err.Error(), "exceeds maximum")185 assert.Contains(t, err.Error(), "exceeds maximum")
235 }186 }
236 187
237 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {188 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
238- // Tokens with expiry > 5 minutes must exist in store189+ // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store
239 tokenStore := mocks.NewMockTokenStore(t)190 tokenStore := mocks.NewMockTokenStore(t)
240 191
241 svc := NewService(Config{192 svc := NewService(Config{
@@ -255,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
255 }).206 }).
256 Return(nil)207 Return(nil)
257 208
258- token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)209+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
259 assert.NoError(t, err)210 assert.NoError(t, err)
260 211
261 // Validation should call GetToken and IsRevoked212 // Validation should call GetToken and IsRevoked
@@ -332,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) {
332 Return(nil)283 Return(nil)
333 284
334 ctx := testContext()285 ctx := testContext()
335- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)286+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
336 require.NoError(t, err)287 require.NoError(t, err)
337 288
338 // Expect GetToken to be called to verify token exists289 // Expect GetToken to be called to verify token exists
@@ -390,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) {
390 Return(nil)341 Return(nil)
391 342
392 ctx := testContext()343 ctx := testContext()
393- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)344+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
394 require.NoError(t, err)345 require.NoError(t, err)
395 346
396 // Expect GetToken to be called to verify token exists347 // Expect GetToken to be called to verify token exists
@@ -720,43 +671,6 @@ func TestService_ListTokens(t *testing.T) {
720 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")671 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
721 }672 }
722 673
723-func TestService_GeneratePasswordHash(t *testing.T) {
724- svc := NewService(Config{}, nil, zerowrap.Default())
725-
726- password := "securePassword123!"
727- hash, err := svc.GeneratePasswordHash(password)
728-
729- assert.NoError(t, err)
730- assert.NotEmpty(t, hash)
731- assert.NotEqual(t, password, hash, "hash should not equal plain password")
732-
733- // Verify the hash works with bcrypt
734- err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
735- assert.NoError(t, err)
736-
737- // Verify wrong password fails
738- err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
739- assert.Error(t, err)
740-}
741-
742-func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
743- svc := NewService(Config{}, nil, zerowrap.Default())
744-
745- password := "testpassword"
746- hash1, err := svc.GeneratePasswordHash(password)
747- require.NoError(t, err)
748-
749- hash2, err := svc.GeneratePasswordHash(password)
750- require.NoError(t, err)
751-
752- // Bcrypt should generate different hashes each time due to salt
753- assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
754-
755- // But both should validate correctly
756- assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
757- assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
758-}
759-
760 func TestService_GenerateToken_HasNbfClaim(t *testing.T) {674 func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
761 // Test that generated tokens include the nbf (not-before) claim675 // Test that generated tokens include the nbf (not-before) claim
762 // This is a security requirement per SEC-MED-006676 // This is a security requirement per SEC-MED-006
@@ -829,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
829 743
830 ctx := testContext()744 ctx := testContext()
831 745
832- // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp746+ // Generate a token with a short remaining life (MaxAccessTokenLifetime+1m) so ExtendToken produces a different exp
833 var capturedToken *domain.Token747 var capturedToken *domain.Token
834 tokenStore.EXPECT().748 tokenStore.EXPECT().
835 SaveToken(mock.Anything, mock.Anything, mock.Anything).749 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -838,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
838 }).752 }).
839 Return(nil)753 Return(nil)
840 754
841- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)755+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute)
842 require.NoError(t, err)756 require.NoError(t, err)
843 757
844 // ExtendToken flow:758 // ExtendToken flow:
@@ -901,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) {
901 815
902 ctx := testContext()816 ctx := testContext()
903 817
904- // Generate a short-lived token (1h) so the first extension produces a different JWT818+ // Generate a short-lived token (MaxAccessTokenLifetime+1m) so the first extension produces a different JWT
905 var capturedToken *domain.Token819 var capturedToken *domain.Token
906 tokenStore.EXPECT().820 tokenStore.EXPECT().
907 SaveToken(mock.Anything, mock.Anything, mock.Anything).821 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -910,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) {
910 }).824 }).
911 Return(nil)825 Return(nil)
912 826
913- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)827+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute)
914 require.NoError(t, err)828 require.NoError(t, err)
915 829
916 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry830 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
M internal/usecase/auto/labels.go +6 -1
@@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo
105105 }
106106 defer reader.Close()
107107
108- configData, err := io.ReadAll(reader)
108+ const maxConfigBlobSize = 1 << 20 // 1 MB
109+ limited := io.LimitReader(reader, maxConfigBlobSize+1)
110+ configData, err := io.ReadAll(limited)
109111 if err != nil {
110112 return nil, fmt.Errorf("failed to read config blob: %w", err)
111113 }
114+ if len(configData) > maxConfigBlobSize {
115+ return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize)
116+ }
112117
113118 labels, err := ParseImageLabels(configData)
114119 if err != nil {
@@ -105,10 +105,15 @@ func ExtractLabels(ctx context.Context, manifestData []byte, blobStorage out.Blo
105 }105 }
106 defer reader.Close()106 defer reader.Close()
107 107
108- configData, err := io.ReadAll(reader)108+ const maxConfigBlobSize = 1 << 20 // 1 MB
109+ limited := io.LimitReader(reader, maxConfigBlobSize+1)
110+ configData, err := io.ReadAll(limited)
109 if err != nil {111 if err != nil {
110 return nil, fmt.Errorf("failed to read config blob: %w", err)112 return nil, fmt.Errorf("failed to read config blob: %w", err)
111 }113 }
114+ if len(configData) > maxConfigBlobSize {
115+ return nil, fmt.Errorf("config blob exceeds maximum size of %d bytes", maxConfigBlobSize)
116+ }
112 117
113 labels, err := ParseImageLabels(configData)118 labels, err := ParseImageLabels(configData)
114 if err != nil {119 if err != nil {
M internal/usecase/container/service.go +17 -0
@@ -51,6 +51,9 @@ type Config struct {
5151 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
5252 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
5353 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
54+ DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit)
55+ DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit)
56+ DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit)
5457 }
5558
5659 var tracer = otel.Tracer("gordon.container")
@@ -229,6 +232,10 @@ type containerConfigInput struct {
229232 }
230233
231234 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
235+ s.mu.RLock()
236+ cfg := s.config
237+ s.mu.RUnlock()
238+
232239 containerName := s.deploymentContainerName(in.Domain, in.Existing)
233240 labels := map[string]string{
234241 domain.LabelDomain: in.Domain,
@@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
266273 Hostname: in.Domain,
267274 Labels: labels,
268275 AutoRemove: false,
276+ MemoryLimit: cfg.DefaultMemoryLimit,
277+ NanoCPUs: cfg.DefaultNanoCPUs,
278+ PidsLimit: cfg.DefaultPidsLimit,
269279 }
270280 }
271281
@@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22322242 }
22332243 envHash := hashEnvironment(envVars)
22342244
2245+ s.mu.RLock()
2246+ cfg := s.config
2247+ s.mu.RUnlock()
2248+
22352249 // Create container with attachment labels (use actual ref, track original in labels)
22362250 config := &domain.ContainerConfig{
22372251 Image: actualImageRef,
@@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
22482262 domain.LabelEnvHash: envHash,
22492263 domain.LabelImage: serviceImage,
22502264 },
2265+ MemoryLimit: cfg.DefaultMemoryLimit,
2266+ NanoCPUs: cfg.DefaultNanoCPUs,
2267+ PidsLimit: cfg.DefaultPidsLimit,
22512268 }
22522269
22532270 container, err := s.runtime.CreateContainer(ctx, config)
@@ -51,6 +51,9 @@ type Config struct {
51 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)51 StabilizationDelay time.Duration // Post-switch monitoring window (default 2s)
52 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)52 TCPProbeTimeout time.Duration // TCP probe timeout (default 30s)
53 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)53 HTTPProbeTimeout time.Duration // HTTP probe timeout (default 60s)
54+ DefaultMemoryLimit int64 // Default memory limit in bytes for containers (0 = no limit)
55+ DefaultNanoCPUs int64 // Default CPU quota in nanoseconds for containers (0 = no limit)
56+ DefaultPidsLimit int64 // Default max PIDs for containers (0 = no limit)
54 }57 }
55 58
56 var tracer = otel.Tracer("gordon.container")59 var tracer = otel.Tracer("gordon.container")
@@ -229,6 +232,10 @@ type containerConfigInput struct {
229 }232 }
230 233
231 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {234 func (s *Service) buildContainerConfig(in containerConfigInput) *domain.ContainerConfig {
235+ s.mu.RLock()
236+ cfg := s.config
237+ s.mu.RUnlock()
238+
232 containerName := s.deploymentContainerName(in.Domain, in.Existing)239 containerName := s.deploymentContainerName(in.Domain, in.Existing)
233 labels := map[string]string{240 labels := map[string]string{
234 domain.LabelDomain: in.Domain,241 domain.LabelDomain: in.Domain,
@@ -266,6 +273,9 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
266 Hostname: in.Domain,273 Hostname: in.Domain,
267 Labels: labels,274 Labels: labels,
268 AutoRemove: false,275 AutoRemove: false,
276+ MemoryLimit: cfg.DefaultMemoryLimit,
277+ NanoCPUs: cfg.DefaultNanoCPUs,
278+ PidsLimit: cfg.DefaultPidsLimit,
269 }279 }
270 }280 }
271 281
@@ -2232,6 +2242,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2232 }2242 }
2233 envHash := hashEnvironment(envVars)2243 envHash := hashEnvironment(envVars)
2234 2244
2245+ s.mu.RLock()
2246+ cfg := s.config
2247+ s.mu.RUnlock()
2248+
2235 // Create container with attachment labels (use actual ref, track original in labels)2249 // Create container with attachment labels (use actual ref, track original in labels)
2236 config := &domain.ContainerConfig{2250 config := &domain.ContainerConfig{
2237 Image: actualImageRef,2251 Image: actualImageRef,
@@ -2248,6 +2262,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2248 domain.LabelEnvHash: envHash,2262 domain.LabelEnvHash: envHash,
2249 domain.LabelImage: serviceImage,2263 domain.LabelImage: serviceImage,
2250 },2264 },
2265+ MemoryLimit: cfg.DefaultMemoryLimit,
2266+ NanoCPUs: cfg.DefaultNanoCPUs,
2267+ PidsLimit: cfg.DefaultPidsLimit,
2251 }2268 }
2252 2269
2253 container, err := s.runtime.CreateContainer(ctx, config)2270 container, err := s.runtime.CreateContainer(ctx, config)
M pkg/validation/path.go +17 -0
@@ -127,6 +127,23 @@ func ValidateUUID(uuid string) error {
127127 return nil
128128 }
129129
130+// ValidateDomainParam validates a domain name extracted from a URL path parameter.
131+// Rejects path traversal, null bytes, and control characters.
132+func ValidateDomainParam(domain string) error {
133+ if domain == "" {
134+ return fmt.Errorf("domain parameter is empty")
135+ }
136+ if strings.Contains(domain, "..") {
137+ return fmt.Errorf("domain contains path traversal sequence")
138+ }
139+ if strings.ContainsRune(domain, '/') || strings.IndexFunc(domain, func(r rune) bool {
140+ return r < 0x20 || r == 0x7f
141+ }) != -1 {
142+ return fmt.Errorf("domain contains invalid characters")
143+ }
144+ return nil
145+}
146+
130147 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.
131148 // Returns the cleaned path or an error if the path is unsafe.
132149 func ValidatePath(path string) (string, error) {
@@ -127,6 +127,23 @@ func ValidateUUID(uuid string) error {
127 return nil127 return nil
128 }128 }
129 129
130+// ValidateDomainParam validates a domain name extracted from a URL path parameter.
131+// Rejects path traversal, null bytes, and control characters.
132+func ValidateDomainParam(domain string) error {
133+ if domain == "" {
134+ return fmt.Errorf("domain parameter is empty")
135+ }
136+ if strings.Contains(domain, "..") {
137+ return fmt.Errorf("domain contains path traversal sequence")
138+ }
139+ if strings.ContainsRune(domain, '/') || strings.IndexFunc(domain, func(r rune) bool {
140+ return r < 0x20 || r == 0x7f
141+ }) != -1 {
142+ return fmt.Errorf("domain contains invalid characters")
143+ }
144+ return nil
145+}
146+
130 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.147 // ValidatePath sanitizes and validates a path component to prevent traversal attacks.
131 // Returns the cleaned path or an error if the path is unsafe.148 // Returns the cleaned path or an error if the path is unsafe.
132 func ValidatePath(path string) (string, error) {149 func ValidatePath(path string) (string, error) {
M pkg/validation/path_test.go +31 -0
@@ -3,6 +3,8 @@ package validation
33 import (
44 "strings"
55 "testing"
6+
7+ "github.com/stretchr/testify/require"
68 )
79
810 func TestValidateRepositoryName(t *testing.T) {
@@ -201,6 +203,35 @@ func TestValidateUUID(t *testing.T) {
201203 }
202204 }
203205
206+func TestValidateDomainParam(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ input string
210+ valid bool
211+ }{
212+ {"valid domain", "example.com", true},
213+ {"valid subdomain", "app.example.com", true},
214+ {"path traversal", "../etc/passwd", false},
215+ {"null byte", "example\x00.com", false},
216+ {"newline", "example\n.com", false},
217+ {"carriage return", "example\r.com", false},
218+ {"empty", "", false},
219+ {"double dot in middle", "foo..bar", false},
220+ {"forward slash", "example/com", false},
221+ {"tab character", "example\t.com", false},
222+ }
223+ for _, tt := range tests {
224+ t.Run(tt.name, func(t *testing.T) {
225+ err := ValidateDomainParam(tt.input)
226+ if tt.valid {
227+ require.NoError(t, err, "input: %q", tt.input)
228+ } else {
229+ require.Error(t, err, "input: %q", tt.input)
230+ }
231+ })
232+ }
233+}
234+
204235 func TestValidatePath(t *testing.T) {
205236 tests := []struct {
206237 name string
@@ -3,6 +3,8 @@ package validation
3 import (3 import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6+
7+ "github.com/stretchr/testify/require"
6 )8 )
7 9
8 func TestValidateRepositoryName(t *testing.T) {10 func TestValidateRepositoryName(t *testing.T) {
@@ -201,6 +203,35 @@ func TestValidateUUID(t *testing.T) {
201 }203 }
202 }204 }
203 205
206+func TestValidateDomainParam(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ input string
210+ valid bool
211+ }{
212+ {"valid domain", "example.com", true},
213+ {"valid subdomain", "app.example.com", true},
214+ {"path traversal", "../etc/passwd", false},
215+ {"null byte", "example\x00.com", false},
216+ {"newline", "example\n.com", false},
217+ {"carriage return", "example\r.com", false},
218+ {"empty", "", false},
219+ {"double dot in middle", "foo..bar", false},
220+ {"forward slash", "example/com", false},
221+ {"tab character", "example\t.com", false},
222+ }
223+ for _, tt := range tests {
224+ t.Run(tt.name, func(t *testing.T) {
225+ err := ValidateDomainParam(tt.input)
226+ if tt.valid {
227+ require.NoError(t, err, "input: %q", tt.input)
228+ } else {
229+ require.Error(t, err, "input: %q", tt.input)
230+ }
231+ })
232+ }
233+}
234+
204 func TestValidatePath(t *testing.T) {235 func TestValidatePath(t *testing.T) {
205 tests := []struct {236 tests := []struct {
206 name string237 name string

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