Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
50281e4
feat(token)!: namespace server-attested private claims under JWT_PRIV…
appleboy May 2, 2026
c89d56f
docs(token): use acme as the example prefix instead of mtk
appleboy May 2, 2026
df9f8b6
fix(token): reserve bare logical names to enforce hard cutover
appleboy May 2, 2026
307558d
refactor(token): unexport private-claim registry and precompute strip…
appleboy May 2, 2026
37bd673
fix(token): close cross-prefix impersonation gap on custom-prefix dep…
appleboy May 2, 2026
ae60948
refactor(token): tighten reserved-list immutability and trust-level w…
appleboy May 2, 2026
c43ff00
test(oidc): cover non-default prefix in discovery claims_supported tests
appleboy May 2, 2026
ec12cc9
refactor(config): test prefix collision via pure helper instead of gl…
appleboy May 2, 2026
d85b890
refactor(services): cache normalized JWT_PRIVATE_CLAIM_PREFIX on Toke…
appleboy May 2, 2026
93d156d
docs(token): refresh stale comment references after prior renames
appleboy May 2, 2026
ad7bcb3
refactor(config,token): unify empty-prefix semantics and harden Valid…
appleboy May 2, 2026
a93d64e
fix(config): add cross-package drift guard and document default-prefi…
appleboy May 2, 2026
0858d2b
fix(token): correct misleading strip-list comment and rename oidcStri…
appleboy May 2, 2026
6daf8ee
fix(token): use DefaultJWTPrivateClaimPrefix constant in test and fix…
appleboy May 2, 2026
7a96343
docs(config): clarify empty JWTPrivateClaimPrefix normalization in fi…
appleboy May 2, 2026
0c772e7
docs(config): fix JWTDomain and JWTPrivateClaimPrefix field comments
appleboy May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,42 @@ SESSION_SECRET=session-secret-change-in-production
# JWT_AUDIENCE= # Example: JWT_AUDIENCE=https://api.example.com
# JWT_AUDIENCE=https://api.example.com,https://admin.example.com

# JWT Domain — server-attested "domain" claim emitted on every issued JWT.
# Identifies which AuthGate deployment minted a token. Must be 1–64 characters
# of letters, digits, underscore, dot, or hyphen, starting and ending with a
# letter or digit. Emitted verbatim (case preserved). Empty → claim omitted
# entirely (no behavior change for existing deployments). The server value is
# the absolute floor: it cannot be set or shadowed via /oauth/token's
# extra_claims parameter, and is re-resolved on every refresh.
# JWT Domain — server-attested claim emitted on every issued JWT under the
# JWT_PRIVATE_CLAIM_PREFIX namespace (default key: extra_domain). Identifies
# which AuthGate deployment minted a token. Must be 1–64 characters of letters,
# digits, underscore, dot, or hyphen, starting and ending with a letter or
# digit. Emitted verbatim (case preserved). Empty → claim omitted entirely.
# The server value is the absolute floor: it cannot be set or shadowed via
# /oauth/token's extra_claims parameter, and is re-resolved on every refresh.
# JWT_DOMAIN= # Example: JWT_DOMAIN=oa

# JWT Private Claim Prefix — namespace token AuthGate prepends (with an
# underscore separator AuthGate adds itself) to every AuthGate-emitted
# private JWT claim. Of those, only `<prefix>_domain` is server-attested
# (set from JWT_DOMAIN); `<prefix>_project` and `<prefix>_service_account`
# are owner-set per-OAuth-client metadata — the JWT signature only proves
# AuthGate emitted them, not that the asserted ownership is verified.
# With the default "extra", JWTs carry extra_domain, extra_project,
# extra_service_account. Setting JWT_PRIVATE_CLAIM_PREFIX=acme would emit
# acme_domain, acme_project, acme_service_account.
#
# Validation: must match ^[a-zA-Z][a-zA-Z0-9_]*$, 1–15 chars, no trailing
# underscore (AuthGate adds the _ itself; rejecting trailing _ prevents
# accidental "extra__domain"), and the composed <prefix>_<logical> keys must
# not collide with any RFC 7519 / OIDC / AuthGate-internal claim. RFC 7519
# standard claims, OIDC public claims, and AuthGate-internal claims (type,
# scope, user_id, client_id) keep their bare names — only AuthGate-emitted
# private claims are prefixed.
#
# *** BREAKING CHANGE FROM PRE-PREFIX RELEASES ***
# Before this feature, AuthGate emitted these claims as bare names ("domain",
# "project", "service_account"). With the default prefix in effect, JWTs now
# carry "extra_domain" / "extra_project" / "extra_service_account" — downstream
# consumers must update at the same time as the AuthGate upgrade. Tokens minted
# pre-upgrade in the token cache must be flushed (or naturally expire) so that
# stale bare-name claims are not served from cache.
# JWT_PRIVATE_CLAIM_PREFIX=extra # Default: extra. Example: JWT_PRIVATE_CLAIM_PREFIX=acme

# JWT Token Expiration
# JWT_EXPIRATION=10h # Access token lifetime (default: 10h). Supports Go duration format: 5m, 1h, 10h
# JWT_EXPIRATION_JITTER=30m # Max random jitter added to access token expiry (default: 30m)
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ Key configuration categories (see `.env.example` and `docs/CONFIGURATION.md` for
- `JWT_SECRET`, `SESSION_SECRET` - Must be changed in production (use `openssl rand -hex 32`)
- `JWT_SIGNING_ALGORITHM` - HS256 (default), RS256, or ES256; asymmetric keys require `JWT_PRIVATE_KEY_PATH`
- `JWT_EXPIRATION` - Access token lifetime (default: 1h); `JWT_EXPIRATION_JITTER` - Random expiry offset to prevent thundering herd
- `JWT_DOMAIN` - Server-attested `domain` claim emitted on every issued JWT (default: empty = claim omitted). Server-set, validated at startup, re-resolved on refresh; not spoofable via `extra_claims` and not exposed in OIDC `claims_supported`
- `JWT_DOMAIN` - Server-attested domain claim emitted on every issued JWT under the `JWT_PRIVATE_CLAIM_PREFIX` namespace (default emitted key: `extra_domain`). Default empty = claim omitted. Server-set, validated at startup, re-resolved on refresh; not spoofable via `extra_claims` and not exposed in OIDC `claims_supported`
- `JWT_PRIVATE_CLAIM_PREFIX` - Namespace prefix AuthGate prepends (with an underscore separator AuthGate adds itself) to every AuthGate-emitted private claim. Of those, only `<prefix>_domain` is server-attested (from `JWT_DOMAIN`); `<prefix>_project` and `<prefix>_service_account` are owner-set per-client metadata. Default `extra` → `extra_domain`, `extra_project`, `extra_service_account`. Setting `JWT_PRIVATE_CLAIM_PREFIX=acme` → `acme_domain`, `acme_project`, `acme_service_account`. Validated at startup: matches `^[a-zA-Z][a-zA-Z0-9_]*$`, 1–15 chars, no trailing underscore, no composed-key collisions with RFC/OIDC/AuthGate-internal claims. **Breaking change vs pre-prefix releases:** bare names `domain` / `project` / `service_account` are no longer emitted; downstream consumers must update at the same time as the AuthGate upgrade and flush the token cache
- `DATABASE_DRIVER` (sqlite/postgres), `DATABASE_DSN` - Database configuration

**Authentication & Authorization**
Expand Down Expand Up @@ -299,7 +300,7 @@ Key configuration categories (see `.env.example` and `docs/CONFIGURATION.md` for

- `EXTRA_CLAIMS_ENABLED` - Master switch for the `extra_claims` form parameter on `/oauth/token` (default: true; set to false to refuse any non-empty `extra_claims`)
- `EXTRA_CLAIMS_MAX_RAW_SIZE` / `EXTRA_CLAIMS_MAX_KEYS` / `EXTRA_CLAIMS_MAX_VAL_SIZE` - Size guards (defaults: 4096 bytes / 16 keys / 512 bytes per value; `0` disables each check)
- Applies to all four grants (`authorization_code`, `device_code`, `client_credentials`, `refresh_token`); reserved JWT/OIDC keys are rejected at parse time, and the standard claims `generateJWT` manages (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `type`, `scope`, `user_id`, `client_id`) plus the OIDC-only ID-token keys it drops (`nbf`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`) cannot survive signing. System claims (`project`, `service_account`) on the OAuth client also override caller values when present
- Applies to all four grants (`authorization_code`, `device_code`, `client_credentials`, `refresh_token`); reserved keys are rejected at parse time. The reserved set is (1) the static RFC/OIDC/AuthGate-internal keys, (2) the prefixed AuthGate-emitted private claims (default: `extra_domain`, `extra_project`, `extra_service_account`), and (3) the **bare** logical names (`domain`, `project`, `service_account`) — reserving the bare names enforces the hard cutover and prevents legacy-name impersonation. The standard claims `generateJWT` manages (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `type`, `scope`, `user_id`, `client_id`) plus the OIDC-only ID-token keys (`nbf`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`) and the bare logical names are also stripped at sign time as defense-in-depth. System claims (`<prefix>_project`, `<prefix>_service_account`) on the OAuth client override caller values when present
- Stateless: claims are NOT persisted, so callers must re-supply `extra_claims` on every refresh request to retain them
- Trust model: caller-supplied claims are self-asserted — downstream resource servers must not treat them as authority-attested

Expand Down
72 changes: 62 additions & 10 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,30 @@ JWT_EXPIRATION_JITTER=30m # Max random jitter on access token expiry
# JWT_AUDIENCE=oa,swrd,hwrd # → "aud": ["oa", "swrd", "hwrd"]

# JWT Domain Claim — server-attested
# Server-set "domain" claim emitted on every issued access, refresh, and
# client-credentials JWT. Identifies which AuthGate deployment minted a token.
# Identifier shape: 1–64 chars of [A-Za-z0-9_.-], starting and ending with an
# alphanumeric (same shape as the per-client `project` claim). Emitted verbatim
# (case preserved). Empty → claim omitted entirely. Server-set: it cannot be
# spoofed via /oauth/token's extra_claims and is re-resolved on every refresh,
# so flipping the env var propagates on the next refresh request.
# Server-set domain claim emitted on every issued access, refresh, and
# client-credentials JWT under the JWT_PRIVATE_CLAIM_PREFIX namespace
# (default key: "extra_domain"). Identifies which AuthGate deployment minted a
# token. Identifier shape: 1–64 chars of [A-Za-z0-9_.-], starting and ending
# with an alphanumeric (same shape as the per-client `project` claim). Emitted
# verbatim (case preserved). Empty → claim omitted entirely. Server-set: it
# cannot be spoofed via /oauth/token's extra_claims and is re-resolved on
# every refresh, so flipping the env var propagates on the next refresh request.
# JWT_DOMAIN= # Default: unset (no domain claim)
# JWT_DOMAIN=oa # → "domain": "oa"
# JWT_DOMAIN=oa # → "extra_domain": "oa" (default prefix)

# JWT Private Claim Prefix — namespace token AuthGate prepends to every
# AuthGate-emitted private claim. Of those, only `<prefix>_domain` is
# server-attested (set from JWT_DOMAIN); `<prefix>_project` and
# `<prefix>_service_account` are owner-set per-OAuth-client metadata.
# With the default "extra" prefix, JWTs carry
# "extra_domain", "extra_project", "extra_service_account". Setting
# JWT_PRIVATE_CLAIM_PREFIX=acme would emit "acme_domain", "acme_project",
# "acme_service_account".
# Validation: must match ^[a-zA-Z][a-zA-Z0-9_]*$, 1–15 characters, no trailing
# underscore, and no composed <prefix>_<logical> may collide with RFC 7519 /
# OIDC / AuthGate-internal claim keys.
# JWT_PRIVATE_CLAIM_PREFIX=extra # Default: extra
# JWT_PRIVATE_CLAIM_PREFIX=acme # → acme_domain, acme_project, acme_service_account

# Refresh Token Configuration
REFRESH_TOKEN_EXPIRATION=720h # Refresh token lifetime (default: 30 days)
Expand Down Expand Up @@ -512,7 +527,7 @@ curl -X POST https://authgate.example/oauth/token \
--data-urlencode 'extra_claims={"tenant":"acme","trace_id":"abc-123","feature_flags":["beta"]}'
```

The supplied JSON object is merged into the JWT alongside standard claims. Reserved JWT/OIDC claim keys (`iss`, `sub`, `exp`, `iat`, `jti`, `aud`, `nbf`, `type`, `scope`, `user_id`, `client_id`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`, `project`, `service_account`) are rejected with `invalid_request` at the parser. As a supplementary guard, `generateJWT` also overwrites the standard claims it manages (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `type`, `scope`, `user_id`, `client_id`) and drops the OIDC-only ID-token keys (`nbf`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`) that have no place in an access token — so a caller-supplied value for any of those cannot survive signing even if it bypasses the parser. System claims set on the OAuth client (`project`, `service_account`) override caller values on collision — admins always win.
The supplied JSON object is merged into the JWT alongside standard claims. Reserved keys are rejected with `invalid_request` at the parser. The reserved set covers (1) the static RFC/OIDC/AuthGate-internal keys (`iss`, `sub`, `exp`, `iat`, `jti`, `aud`, `nbf`, `type`, `scope`, `user_id`, `client_id`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`), (2) the **prefixed** AuthGate-emitted private claims composed from `JWT_PRIVATE_CLAIM_PREFIX` and the registry — by default `extra_domain`, `extra_project`, `extra_service_account`, (3) the **bare** logical names from the registry (`domain`, `project`, `service_account`), and (4) the **default-prefixed** forms (`extra_domain`, `extra_project`, `extra_service_account`) **unconditionally, even when `JWT_PRIVATE_CLAIM_PREFIX` is set to a custom value**. This prevents cross-prefix impersonation during prefix migrations: without it, a deployment running `JWT_PRIVATE_CLAIM_PREFIX=acme` would accept a caller-supplied `extra_domain` claim that lands in the signed JWT, fooling any un-migrated downstream consumer that still reads the default key. Reserving the bare names enforces the hard cutover: without it, a caller could re-introduce the legacy pre-prefix keys an un-migrated downstream consumer might still trust. As a supplementary guard, `generateJWT` also overwrites the standard claims it manages (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `type`, `scope`, `user_id`, `client_id`) and drops the OIDC-only ID-token keys (`nbf`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`) **and the bare logical names** that have no place in an access token — so a caller-supplied value for any of those cannot survive signing even if it bypasses the parser. System claims set on the OAuth client (`<prefix>_project`, `<prefix>_service_account`) override caller values on collision — admins always win.

### Configuration

Expand All @@ -525,14 +540,51 @@ The supplied JSON object is merged into the JWT alongside standard claims. Reser

### Stateless behaviour

Custom claims are **not persisted** server-side. To keep them on a refreshed token, the caller must re-supply `extra_claims` on every refresh request. Omitting the parameter on refresh produces a token with no caller claims (system claims like `project` / `service_account` still flow through from the OAuth client record).
Custom claims are **not persisted** server-side. To keep them on a refreshed token, the caller must re-supply `extra_claims` on every refresh request. Omitting the parameter on refresh produces a token with no caller claims (system claims like `<prefix>_project` / `<prefix>_service_account` still flow through from the OAuth client record).

### Trust model

The signature only proves AuthGate emitted these values, not that they are authoritative. Downstream resource servers must treat caller-supplied claims as **self-asserted** and apply their own access policies — never make authorization decisions on `extra_claims` values without independent verification. See [`docs/JWT_VERIFICATION.md`](JWT_VERIFICATION.md) for the full trust model.

---

## AuthGate-Emitted Private Claim Prefix (Breaking Change)

AuthGate emits three private claims on every issued JWT under a deployment-configurable namespace prefix. The trust level differs per claim:

| Logical name | Source | Trust level | Default emitted key |
| ----------------- | ------------------------------------- | --------------- | ------------------------ |
| `domain` | `JWT_DOMAIN` env var | server-attested | `extra_domain` |
| `project` | `OAuthApplication.Project` | owner-set | `extra_project` |
| `service_account` | `OAuthApplication.ServiceAccount` | owner-set | `extra_service_account` |

Only `<prefix>_domain` is sourced from deployment configuration and therefore carries server-attested trust. `<prefix>_project` and `<prefix>_service_account` come from the OAuthApplication row (admin- or client-owner-set) — a JWT signature only proves AuthGate emitted these values, not that the asserted ownership was independently verified. Downstream services should still apply their own policies on top.

The composed key is `<JWT_PRIVATE_CLAIM_PREFIX>_<logical>`. AuthGate adds the separating underscore itself; setting `JWT_PRIVATE_CLAIM_PREFIX=acme` produces `acme_domain` / `acme_project` / `acme_service_account`.

### Configuration

| Variable | Default | Purpose |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `JWT_PRIVATE_CLAIM_PREFIX` | `extra` | Namespace prefix for AuthGate-emitted private claims. Validated at startup. |

### Validation rules (startup)

- Must match `^[a-zA-Z][a-zA-Z0-9_]*$` — start with a letter, then letters / digits / underscores only.
- 1–15 characters total.
- No trailing underscore (AuthGate adds the `_` itself; rejecting trailing `_` prevents `extra__domain`).
- No composed `<prefix>_<logical>` may collide with an RFC 7519 / OIDC / AuthGate-internal claim key (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `type`, `scope`, `user_id`, `client_id`, `azp`, `amr`, `acr`, `auth_time`, `nonce`, `at_hash`).

### Migration from pre-prefix releases (BREAKING)

Releases before this feature emitted these claims as **bare** names: `domain`, `project`, `service_account`. With this release, the bare names are gone; claims are always emitted under the prefix. Downstream services that read `claims["domain"]` directly must update to `claims["extra_domain"]` (or the operator-chosen prefix) at the same time as the AuthGate upgrade.

**Token cache:** tokens minted before the upgrade may still be in the cache with bare-name claims. On upgrade, flush the token cache (Redis FLUSH or restart with empty memory cache) or wait for tokens to expire naturally. Bumping the cache key namespace is recommended only if you need an instantly-clean cutover.

**Rollback:** revert the deploy. Tokens minted under the new code carry only prefixed claims; consumers that already migrated to read prefixed names will need a coordinated revert.

---

## Default Test Data

The server initializes with default test accounts:
Expand Down
Loading
Loading