All notable changes to the d-OAuth-Server authorization server are recorded here.
The format follows Keep a Changelog,
and the project uses Semantic Versioning.
While the version stays below 1.0.0, a minor bump may carry a breaking change.
cimd_enabled on, an https://
client_id resolves to a fetched, strictly validated metadata document with
no registration row and no initial-access token. The fetch is https only,
follows no redirects, pins DNS, disables the proxy, rejects
special-use/private/loopback targets, and caps the body (cimd_max_bytes)
and duration (cimd_fetch_timeout_secs); only none and private_key_jwt
are accepted, with inline jwks only and no wildcard redirect URIs.
Documents are cached under {state} for 15 minutes, with a 30-second
origin-failure negative cache and a 2-second invalid-document marker.resource parameter on /authorize,
/par, /token, /device_authorization, and /introspect (the last as a
local RFC 7662 extension) binds the token audience to the enabled
resource_servers catalog. The catalog is exact-match only (no wildcard,
prefix, or normalization) and is managed with
resource-server create|list|enable|disable|delete or config sync; the
feature is gated by resource_indicators_enabled. The AS advertises
resource_indicators_supported (a local RFC 8414 extension) only while the
flag is on. The audience is pinned into the pending/PAR/code/device records
and must be re-presented exactly at redemption; duplicates are deduplicated
and at most 32 raw values are accepted per request.at+jwt profile), opt-in twice: the
deployment switch jwt_access_tokens_enabled and a per-client
access_token_format = jwt set only by the operator with
client set-access-token-format --client-id <id> --format opaque|jwt.
Refresh tokens and the grant chain stay opaque and hashed. While the switch
is on the AS advertises jwks_uri at /jwks.json and
access_token_signing_alg_values_supported, serves an RFC 7517 key set with
retained verify-only keys, and keeps a Redis jwt:<jti-hash> allowlist
marker so AS-side revocation, introspection, and refresh observe a kill; a
resource server that verifies locally cannot see that marker before exp
(see SECURITY.md). Signing is ES256 or EdDSA over raw
32-byte keys with kid rotation.protected_resources AS metadata, gated by
protected_resources_enabled and protected_resource_ids, plus the
resource-server contract in RS-CONTRACT.md and the
onboarding runbook in
OPERATIONS.md.allow_localhost_redirect opts the RFC 8252 loopback exception in for the
host localhost; 127.0.0.1 and [::1] stay loopback regardless.v* git tag builds and pushes the
signed multi-arch image under semver tags (X.Y.Z, X.Y, X, latest; a
pre-release gets only X.Y.Z) and creates a GitHub Release. The image path is
ghcr.io/syavne/d-oauth-server.pii_encryption_key, and the looked-up or unique values carry a
keyed-HMAC blind index. TOTP secrets move to a per-row envelope (blob version
3) so a DEK can be re-wrapped without re-encrypting the secret. New
pii backfill, pii verify, and pii rotate commands run the one-shot
migration and PII key rotation, and totp rewrap re-wraps the TOTP DEKs. The
expansion adds _enc/_index columns; the backfill blanks the plaintext
columns but this release does not drop them (the contract drop is a later
release).docs/DATABASE.md, docs/SECURITY.md,
docs/CONFIGURATION.md, and docs/OPERATIONS.md.GET /logout and POST /logout (a server-specific session endpoint, not
an OAuth 2.1 or OIDC feature) end the browser session so a different user
can sign in. GET /logout is read-only: it renders a confirm page (live
session) or a neutral signed-out page (anonymous) and never destroys the
session or writes cookies. Only POST /logout, carrying the session-bound
CSRF form_token, destroys the session and expires the cookie, then renders
a neutral page or redirects to
the flow's registered post_logout URI. It accepts no query parameters: the
redirect target is frozen in session meta when the authorization flow is bound
and used only while the owning client is active and still registers that exact
URI (exact-string match, with the RFC 8252 loopback exception). There is no
post_logout_redirect_uri parameter, no id_token_hint, and no
end_session_endpoint in metadata.redirect_uris catalog gains a kind column (authorize | post_logout);
existing rows backfill to authorize. redirect-uri create|enable|disable|delete
and client attach|detach-redirect-uri accept --kind, and config sync file
entries accept a kind field. A client may register at most one enabled
post_logout URI.GET /assets/app.css (src/ui/app.css). It
adds no JavaScript and needs no CSP change: the page policy's existing
style-src 'self' covers it, so script-src 'none' still holds.otpauth:// URI as a server-side inline SVG
QR code beside the text secret and URI, so users can scan instead of
hand-typing. The new qrcode dependency is pinned SVG-only (no image
codecs); the text secret, URI, and one-time recovery codes are unchanged.docs/SECURITY.md gains the
dump blast-radius table and the ordered rotation (token pepper, TOTP key,
PII field-encryption key, client secrets, DCR registration tokens), and RUNBOOK.md gains the
matching operator checklist with concrete commands.deploy/k8s: the token pepper and TOTP key mount as
0400 tmpfs files under /run/secrets (read through
D_OAUTH_TOKEN_PEPPER_FILE / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, mirrored
in compose.yaml), the image reference defaults to the mutable :latest
tag (a digest pin is available but not the default; see
docs/DEPLOYMENT.md), a NetworkPolicy
default-denies pod egress except DNS plus Postgres/Redis scoped to their
namespace (no internet-wide 0.0.0.0/0 peer; scripts/check_manifests.py
rejects one), and automountServiceAccountToken is false. Remote JAR
request_uri is documented as unsupported under the default-deny, with the
Cilium FQDN opt-in path in docs/DEPLOYMENT.md.user disable now purges all subject-scoped Redis state for the subject (sessions and their
meta, codes, devices with their sidecars and mappings, grants via cascade
revoke, standalone tokens, and the lockout counter) before flipping
users.disabled; records without a subject (PAR, pending, DPoP and
private_key_jwt replay markers, rotation map, TOTP replay, rate-limit
counters) are untouched. A Redis failure aborts before the flag changes, so the
command fails closed.POST /revoke authenticates mTLS-registered clients (RFC 8705 §2) with the
verified peer chain injected by the TLS accept loop, exactly as the token and
introspection endpoints do; a missing or unregistered chain fails closed as
invalid_client. Client certificates are expected to be short-lived, so
expiry bounds exposure without a revocation list.token_pepper_previous_key / token_pepper_previous_key_file with an
explicit token_pepper_previous_key_id lets a rotated pepper keep verifying
hashes issued under the old one, so rotation no longer invalidates live
opaque tokens.dpop_bound_access_tokens: true; a DPoP-bound public client still
presents a fresh proof at every gate. Operators that require DPoP for public
clients must register them with the flag, because unbound public clients
become bearer-capable on upgrade.access_token_format = jwt; dynamic registration always stores opaque, and
a jwt client fails closed with 503 while jwt_access_tokens_enabled is
off instead of falling back to opaque.compose.test.yaml instead of starting a container per test. Only the
lifecycle, topology, durability, and dedicated-node suites keep
testcontainers. The fuzz/ workspace stays outside the root gates and is
built by CI; build it locally when a change reshapes a type or function a
fuzz target constructs.pii_dual_write (default false, encrypt-only) turns on
dual-write for the rolling-upgrade window; readiness fails closed while the
switch is off and any blanked plaintext PII column still holds a value.TOTP secrets now use a per-row envelope (blob version 3): a random DEK
wrapped by AES-256-GCM under HKDF-SHA256 (info totp-kek-v1), so
totp rewrap re-wraps the DEK without re-encrypting the secret. Versions 1
(bare SHA256(totp_key)) and 2 (HKDF info totp-gcm-v2) keep decrypting
through a legacy fallback, so no re-enrollment is needed.
GET /.well-known/oauth-authorization-server now carries an explicit
Cache-Control: public, max-age=3600 instead of relying on browser
heuristic freshness, so metadata caching is intentional and tunable. The
plaintext health listener (which does not mount the public router's
security-header layer) now sets Cache-Control: no-store and
X-Content-Type-Options: nosniff on its own probe responses, with a
regression test pinning both.
Forwarded client-cert and IP headers are never trusted; the dead proxy-header fallback is removed.
The login lockout now also covers TOTP enrollment confirmation, and the rate limiter classifies requests by their full path.
Key ids are String-backed and preserved verbatim end to end: a vN id
mixes the 4-byte big-endian generation into the token HMAC message
(byte-identical to the pre-change scheme) and any other id mixes its UTF-8
bytes, stored as <kid>.<digest>, so non-vN labels hash distinctly and
every trim/parse::<u32> collapse site is gone. Back-compat is scoped to
vN ids: hashes issued under an old vN id keep verifying through the
retained-key chain (newest-first, capped at 8 generations), so rotation
needs no re-issuance.
resource_servers row is a kill switch for that
audience. Refresh and device mints re-validate the stored audience against the
current enabled catalog, so a disabled/deleted member (or the indicator flag
turned off) fails invalid_target instead of minting: a refresh token stays
live for a retry, a device code is already consumed by the poll, and an
authorization code is validated before redemption so it survives a bad
resource. Re-enabling the identifier lets the client re-authorize or retry
the live refresh token./token, /introspect, and /device_authorization authenticate the client
before validating resource values, so an unauthenticated caller observes
invalid_client rather than a catalog error.https URI
with a host and no userinfo, query, or fragment, exact-match against the
enabled catalog; values containing an ASCII control character are
invalid_target before any catalog lookup.503 temporarily_unavailable and
emits a pii.decrypt_failure audit event carrying only the request id, never
a value. Full-blob substitution by an attacker with database write access
remains out of the threat model and is documented in docs/SECURITY.md.users.disabled, and refresh re-checks
the subject's current entitlements; a disabled subject introspects inactive
and refresh rejects with invalid_grant before any token is minted.WeakKeyMaterial.private_key_jwt assertion lifetime is capped and its jti replay marker
now tracks the remaining validity; PS256 is added to the assertion
algorithm allowlist.open redirect policy now accepts only https or RFC 8252 loopback
URIs, and the SSRF guard pins the fetch to the addresses it pre-validated.Debug: the pepper,
TokenService, and minted pairs print ids/lengths plus a [REDACTED]
marker, never raw pepper bytes or token strings, and the located-token
wrappers stay transitive through identifier-only records. Positive
[REDACTED] assertions pin the markers.no-store, nosniff) on
every short-circuit path: limiter 429/503, CORS preflights, body-limit
413, and resilience 503.RS256/PS256/ES256/EdDSA, kept in sync with the private_key_jwt
assertion allowlist); anything else is rejected before key selection.POST /logout verifies the session-bound CSRF form_token and rejects a
missing or forged token with invalid_request leaving the session intact,
so a cross-site POST cannot sign the user out; GET /logout is read-only
(confirm page, no Set-Cookie) and the clearing cookie mirrors every
attribute of the session cookie.redirect or metadata), so a retry within the 2-second window replays the
original invalid_redirect_uri or invalid_client_metadata instead of a
generic failure; an unknown or legacy marker fails closed as the metadata
class.resource fails invalid_target
without burning a live code; refresh and device mints also re-check the stored
audience against the current catalog before minting.https:// client row, so deactivation cannot be bypassed, and an inline
jwks on a none document is ignored.503.token_type (DPoP for a cnf.jkt token, Bearer for
unbound or mTLS-only tokens, omitted for refresh) and filters with the
validated audience set.jwt
client fails invalid_target rather than minting aud: [].GET /.well-known/oauth-authorization-server reads the enabled scope catalog
from Postgres on every request. A scope added, enabled, or disabled via the
CLI now appears in scopes_supported immediately on every replica, instead of
only after a restart; the previous in-process cache was never invalidated by
the out-of-process CLI mutation.Cache-Control: no-store
(+ Pragma: no-cache) except /.well-known/… metadata, so authorization and
consent pages, token bodies, request handles, and errors are never cached by
a browser, the back/forward cache, or an intermediary. Token-endpoint error
responses now carry no-store, closing a documented gap against
draft-ietf-oauth-v2-1-16 §3.2.3.GET /login?request_id=… rebinds an already-authenticated session to the
fresh authorization request instead of redirecting on the session's stale
pending binding.OOM, READONLY, CROSSSLOT,
auth, and connection-type errors) fail closed as 503
temporarily_unavailable instead of 500, while programming-error classes
stay masked as server_error so 500-alerts still fire on real bugs.client_assertion JOSE stub
(the real logic lives in protocol::client_auth::assertion) and the
caller-less store::grants::add_token helper.jose module TODO no longer lists
the deleted stub, the token shape-gate comment no longer claims to mask
generation probing, and the two algorithm allowlists carry keep-in-sync
pointers.decide runs a single conditional-HSET Lua script instead of
EXISTS-then-HSET, so a concurrent consume cannot leave a zombie record;
the device poll marker is a Lua compare-and-set admitting exactly one poller
(the rest get slow_down).consume_bound over client_id, plus redirect_uri/PKCE challenge for
codes): a foreign-client or wrong-verifier presentation leaves the single-use
record intact instead of burning the victim's value.redirect_uri on
grant_type=authorization_code and skips the binding check
(draft-ietf-oauth-v2-1-16 §4.1.3, §10.2); a supplied value must still match
exactly or redemption fails invalid_grant.com.example.app:/ is accepted and
myapp:/ is rejected with invalid_redirect_uri on DCR and invalid input
on the CLI; https and the RFC 8252 loopback exception are unchanged.enabled catalog, client registration, and user
entitlements (revoked entries drop silently), while an explicit narrower
scope narrows only the access token and the response scope.revoke drains in a bounded loop until the member set reads
empty, and the rotation script carries a grant-record fence (NOGRANT
before any write), so a token minted mid-cascade is deleted by a later pass
or refused outright.RotatedUnbarriered on rotation) or deletes the won DPoP marker so the
originating jti can re-claim, instead of locking the client out; other
callers stay fail-closed (a Replay hit discards its orphan pre-mint and
rejects with invalid_grant).The first release. This is an OAuth 2.1-only authorization server, not a backwards-compatible OAuth 2.0 server. The deltas are deliberate: see OAUTH.md for the exact differences.
S256; plain and missing
challenges are rejected. Implicit and resource-owner-password grants do not
exist.rotation_request_id, so a retried
request returns the same tokens instead of double-rotating.client_credentials, device-code (RFC 8628, with the user-facing /device
page), and policy-gated token-exchange (RFC 8693) grants.private_key_jwt is client authentication
only and is never treated as a sender constraint.scopes_supported served from
the enabled scopes table. The document carries no jwks_uri: the server
signs no artifact of its own.requested ∩ client-registered ∩ user-entitled ∩ enabled.migrate, serve, user,
scope, redirect-uri, client, config sync, config check,
admin bootstrap, initial-access-token issue, keygen, and healthcheck.
There is no HTTP admin API and no default credential.503 temporarily_unavailable with Retry-After on security paths, never a
fail-open success and never a 500 or panic.healthcheck subcommand that probes the plaintext health
listener without a shell or curl.{state} hash tag.#![forbid(unsafe_code)] across the crate; unwrap, expect, and panic
are denied by lint in request paths.frame-ancestors 'none', X-Content-Type-Options,
Referrer-Policy, and HSTS on the TLS listener.