OIDC scopes with haudience #2

Merged
nhpro merged 33 commits from fix/oidc-scopes-with-audience into master 2026-05-19 15:20:43 +02:00
Owner
No description provided.
Exposes a pure OAuth 2.0 authorization server metadata document at
/.well-known/oauth-authorization-server, distinct from the existing
OIDC discovery at /.well-known/openid-configuration.

The new struct OAuthAuthorizationServerMetadata omits OIDC-specific
fields (subject_types_supported, id_token_*, userinfo_endpoint,
end_session_endpoint, *channel_logout_*, claims_supported,
check_session_iframe) so that pure OAuth resource servers probing
this URL get only the metadata that applies to them, per RFC 8414.
Validates:
- OIDC discovery returns all OIDC Core mandatory fields (issuer,
  endpoints, subject_types, id_token_signing_alg, claims).
- RFC 8414 OAuth metadata returns its mandatory fields and OMITS
  OIDC-only ones (userinfo_endpoint, *_logout, claims_supported, etc.).
- Both documents agree on endpoint URLs that overlap.
Adds a per-client HMAC key stored encrypted (AES-GCM) and used to
verify client_secret_jwt assertions per RFC 7523 §2.2. NULL means the
client has not opted in to client_secret_jwt and only conventional
secret-based or private_key_jwt auth applies.

The encryption layer and middleware branch land in follow-up commits.
Adds GenerateHMACSecret, EncryptHMACSecret, DecryptHMACSecret and
DecodeHMACEncryptionKey alongside a new config.Auth.HMACSecretEncryptionKey
(base64 32 bytes). The encryption layer seals per-client HMAC keys at
rest so the server can still recover them to validate HS256
client_secret_jwt assertions, without persisting them as plain bytes.

Round-trip, wrong-key, length-validation and base64-variant tests
included.
Extracts the shared RFC 7523 claim validation (sub, iss, aud, jti, exp)
out of ValidateClientAssertionJWT into validateAssertionCommonClaims so
both signature paths can reuse it.

Adds ValidateClientSecretJWT which dispatches on the HMAC signing
method (HS256) using a per-client decrypted HMAC key passed in by the
caller. Requires WithExpirationRequired + WithValidMethods(["HS256"])
so a downgraded/none alg JWT cannot bypass verification.
ClientAuth now inspects the JWT header alg of incoming client_assertion
JWTs and dispatches:
- HS256 → ValidateClientSecretJWT using the per-client HMAC key
  decrypted from oauth_clients.secret_hmac_key with the server-side
  AES-256 encryption key.
- RS256/ES256/PS256 → existing ValidateClientAssertionJWT against the
  client's JWKS URI.

resolveAssertionClient now returns the alg, sealed HMAC key and JWKS
URI in a single trip so the dispatcher doesn't need a second DB read.

The new hmacEncryptionKey parameter on ClientAuth comes from
auth.hmac_secret_encryption_key. When the key is not configured,
client_secret_jwt assertions are rejected with a clear error instead
of failing silently.
When a client is provisioned with token_auth_method=client_secret_jwt,
the service now:
- generates a 32-byte random HMAC secret
- seals it with the server-side AES-256 encryption key
- stores the ciphertext in oauth_clients.secret_hmac_key
- returns the plaintext (URL-safe base64) once in CreateResponse.HMACSecret

A new RotateHMACSecret(id) entry point regenerates the HMAC key on
demand (rejecting clients whose token_auth_method isn't
client_secret_jwt). The previous key is dropped, so any pending
assertion signed with it stops verifying.

The service refuses to provision HMAC-based clients when
hmacEncryptionKey is nil (server not configured), with a clear error.
Adds POST /api/v1/admin/clients/{id}/rotate-hmac-secret returning the
fresh HMAC secret exactly once. Audit log entry uses
ActionClientSecretRotated with kind="hmac" to distinguish from
classic secret rotation.

main.go wires the AES-256 HMAC encryption key (loaded once via the new
loadHMACEncryptionKey helper) into both the client service and the
ClientAuth middleware so both sides can seal/unseal coherently. When
auth.hmac_secret_encryption_key is unset the helper logs a warning and
returns nil, leaving the rest of the server functional but disabling
client_secret_jwt at runtime.
Adds client_secret_jwt to token_endpoint_auth_methods_supported for
both /.well-known/openid-configuration (OIDC Core) and
/.well-known/oauth-authorization-server (RFC 8414), and to the
*_endpoint_auth_methods_supported mirrors on the latter.

The middleware now dispatches HS256 client assertions through the
per-client HMAC key path, so this advertisement matches the actual
runtime support.
Covers:
- happy path returns the client_id from sub
- wrong HMAC key fails signature verification
- nil hmacKey is rejected upfront
- expired token is rejected via WithExpirationRequired
- wrong audience claim is rejected
- missing jti claim is rejected
- a header lying alg=RS256 is rejected because the parsed signing
  method is not HMAC (defense against algorithm-confusion attacks)
Adds a pq.StringArray column oauth_clients.request_uris populated by
DCR (RFC 7591) and consulted by /authorize before fetching a remote
Request Object (RFC 9101 §5.2.2). HasRequestURI helper mirrors the
existing HasRedirectURI/HasPostLogoutRedirectURI pattern.
DCRRequest/DCRResponse, CreateInput, UpdateInput and UpdateRegistration
all gain an optional request_uris field which the service copies onto
the model. UpdateRegistration uses non-nil-slice semantics so callers
can clear the whitelist by sending []. The admin Update endpoint shares
the same path via service.Update.
New helper module that:
- Verifies signed JAR Request Objects against the client's JWKS using
  the shared middleware.JWKSCache (RS/PS/ES alg families). alg=none is
  rejected outright; an unsigned request object would let any caller
  override authorize parameters on behalf of the client.
- FetchRequestURI does the GET with a 5s timeout, 1 MiB body cap,
  Accept: application/oauth-authz-req+jwt header, and caches successful
  bodies for 5 minutes so /authorize bursts don't hammer the RP.
Two changes in one because they share the same verifier and
introducing the verifier without using it would leave the bug open.

1) Inline JAR (?request=JWT) used parseRequestObject which called
   ParseUnverified. An attacker could forge an unsigned JWT and
   override every authorize parameter (scope, audience, redirect_uri,
   …) on behalf of the client. The handler now goes through
   ParseAndVerifyRequestObject which mandates a signed JWT verified
   against the client's JWKS.

2) request_uri now branches on the URI scheme:
   - urn:ietf:params:oauth:request_uri:… → existing PAR flow
   - http(s)://… → must be in client.RequestURIs, then FetchRequestURI
     + ParseAndVerifyRequestObject (RFC 9101 §5.2.2)

The handler grew a SetJWKSCache wiring so the shared cache the
ClientAuth middleware already uses is reused for request object
signature verification. parseRequestObject (unverified) is removed.
Now that /authorize accepts both inline signed Request Objects and
remote ones via whitelisted request_uri, the metadata documents must
reflect that:

- request_uri_parameter_supported flips to true on both endpoints.
- request_object_signing_alg_values_supported lists the alg families
  ParseAndVerifyRequestObject accepts (RSA, RSA-PSS, ECDSA).

alg=none is intentionally NOT advertised because the verifier rejects
it; clients trying it will get a clear error.
Covers:
- happy path: signed RS256 JWT verified against a test-spun JWKS server
- alg=none forged JWT is rejected before any signature work
- JWT signed with a different key fails verification
- client without jwks_uri can never produce a verified request object
- FetchRequestURI sends Accept: application/oauth-authz-req+jwt
- bodies over 1 MiB are refused even if the server keeps writing
- non-200 status from the RP fails fast
Adds three schema bits needed for the optional JWT access token path:

- api_resources.token_format ('opaque' default, 'jwt' to opt in). The
  branchment cannot live on signing_alg because that column already
  defaults to 'RS256' and is never NULL.
- revoked_jtis (jti PK, expires_at): denylist consulted by
  /introspect and populated by /revoke for JWT access tokens.
- access_tokens.jti (nullable): set when the row tracks a JWT so
  /revoke can find the JTI without re-parsing the JWT body.

Model helpers: APIResource.EmitsJWTAccessTokens() and the
TokenFormatOpaque/TokenFormatJWT constants for callers to switch on.
GenerateAccessTokenJWT signs an RFC 9068 access token using the same
RSA signing key that backs ID tokens, with header typ="at+jwt" + kid.
Claims: iss, sub (user or client), aud (resource identifier), exp,
iat, jti (uuid v7 — also returned to the caller for denylist tracking),
client_id, scope (space-joined string per RFC 6749).

User-bound tokens with scope="roles" get the "roles" and "groups"
claims via the shared enrichClaimsWithRoles helper; client_credentials
tokens never carry roles.

ExtraClaims (policy modify hook) are merged but cannot override the
RFC 9068 reserved set.

ValidateAccessTokenJWT verifies signature + iss + expiry using the
JWKSet (active key plus grace-period keys) so resource server-style
introspection works across key rotations.

The server today only signs with RS256; resource configs may ask for
RS384/RS512/PS256 but the server falls back to RS256 with the active
key. Anything else is rejected at issuance.
Both issueTokensWithOpts (auth_code, refresh_token, device_code) and
ExchangeClientCredentials now resolve the full APIResource, and when
TokenFormat=='jwt' (and a signer is wired) hand off to
oidc.GenerateAccessTokenJWT instead of crypto.GenerateOpaqueToken.

The access_tokens row keeps its primary key = sha256(raw_token) for
both formats, so /revoke can locate it without parsing the JWT. The
new JTI column is populated for JWT rows so /revoke can also push the
JTI to revoked_jtis.

When the resource asks for JWT but no signer is configured we log a
warning and fall back to opaque rather than failing the flow — that
way the server can boot without oidc wired during early bring-up.

A small helper interface AccessTokenJWTSigner + adapter mirror the
existing IDTokenGenerator pattern to keep oauth free of oidc imports.
TTLs now honour resource.AccessTokenTTL when present (the auth code
path was always using client.AccessTokenTTL, which lost the
per-resource override).
Adds IsJTIRevoked, RevokeJTI (idempotent INSERT … ON CONFLICT DO NOTHING)
and PurgeExpiredRevokedJTIs to the repository interface so the upcoming
introspect + revoke branches can consult and populate the denylist
without leaking GORM into the service layer.

Mock repo extended with matching no-op stubs so service tests keep
building.
Introspect now tries JWT verification before the opaque hash lookup
when:
- the token has exactly two dots (cheap pre-check)
- a JWT signer is wired (otherwise we never issued any)
- the caller's hint allows access_token (no hint = both)

The JWT branch:
- verifies signature + iss + exp via oidc.ValidateAccessTokenJWT
- checks revoked_jtis (denylist takes precedence — returns Active=false)
- reuses deniedByIntrospectPolicy so cross-client introspection rules
  apply identically to JWT and opaque tokens
- exposes username only when the requesting client owns the token
  (matches the existing opaque-path policy)

Falls through to the opaque hash lookup if the JWT branch returns nil,
keeping behaviour compatible during opt-in rollout.
When a /revoke call lands on a JWT access token, we verify the
signature first, check the client_id claim against the requesting
client (RFC 7009: silent return on mismatch), then push the JTI to
revoked_jtis with expires_at = claim.exp. Subsequent /introspect
calls flip Active to false.

The opaque hash lookup still runs after the JWT branch so the
access_tokens row (kept for audit / revocation correlation) is also
marked revoked when present. When the row is absent — e.g. an
external introspect after a clean DB — the denylist is the kill
switch.
Hooks the existing database/cleanup.go hourly job to DELETE from
revoked_jtis WHERE expires_at < NOW(). Expired JTIs can no longer
pass JWT signature/expiry validation, so keeping them in the denylist
is pure dead weight; this bounds the table by access token TTL.
Adds the (non-standard but Auth0-style) discovery field so resource
servers can detect that this issuer signs RFC 9068 access tokens with
RS256. Listed on both /.well-known/openid-configuration and the
RFC 8414 metadata. Omitempty so the field disappears if we ever start
emitting only opaque tokens server-wide.
Covers the bits the rest of the JWT access token flow leans on:
- user-bound token sub=user_id, no roles claim absent scope=roles
- client_credentials token sub=client_id
- header typ=at+jwt and kid populated (RFC 9068 §2.1)
- non-RSA algs rejected (server only signs with the RS256 active key)
- ExtraClaims merges custom claims but cannot override reserved ones
  (iss, sub, aud, exp, iat, jti, client_id, scope)
- issuer mismatch fails verification (defense against confused-deputy)
Stub AccessTokenJWTSigner keeps the test free of oidc imports while
exercising the dispatcher precisely:
- happy path: JWT introspect returns Active=true with scope, aud, sub
- denylist precedence: flipping IsJTIRevoked returns Active=false even
  when the JWT signature is still verifiable
- revoke writes the JTI to the denylist with the JWT's exp as TTL
- cross-client revoke is a silent no-op per RFC 7009 — the denylist
  is NOT touched when the caller doesn't own the token
Four nullable columns on oauth_clients matching the OIDC Core /
RFC 7591 DCR vocabulary:
- id_token_encrypted_response_alg/enc
- userinfo_encrypted_response_alg/enc

NULL means the client doesn't opt in and the existing JWS (or JSON)
response shape is preserved. The matching encryption logic and DCR
mapping land in follow-up commits.
Promotes lestrrat-go/jwx/v3 from indirect to direct dependency and
adds a self-contained encryption module:

- EncryptForClient(payload, jwks_uri, alg, enc) → JWE compact string
- pickEncryptionKey prefers use=enc keys, falls back to keys with no
  use claim, skips use=sig keys
- ValidateJWEEncryptionPair used by DCR to reject unsupported configs
- a small in-process JWKS cache (5min TTL, 1 MiB body cap) keeps RPs
  honest without re-fetching on every token issuance
- SupportedJWEAlgs / SupportedJWEEncs power discovery advertisement

No callers yet; ID token + UserInfo wrapping land in follow-ups.
GenerateIDToken: when EncryptionAlg + EncryptionEnc + EncryptionJWKSURI
are set on IDTokenClaims, the signed JWS is wrapped in a JWE
(nested JWS-in-JWE per OIDC Core §10.2) using EncryptForClient.

issueTokensWithOpts pulls the three encryption parameters off the
OAuthClient row (id_token_encrypted_response_*, jwks_uri) and feeds
them through the IDTokenAdapter into the oidc service. Clients that
haven't opted in see the unchanged signed JWS.

UserInfo handler grows three branches selected by client config:
- plain JSON (default)
- signed JWT when userinfo_signed_response_alg is set
- JWE when userinfo_encrypted_response_alg/enc are both set; in that
  case the response is signed first then wrapped, and the handler
  fails the request with 500 if the client lacks a jwks_uri (we
  refuse to silently downgrade to plaintext).
DCRRequest and DCRResponse gain id_token_encrypted_response_alg/enc
and userinfo_encrypted_response_alg/enc plus a jwks_uri pass-through.

validateEncryptionPair rejects unsupported algs / partial
configurations early with invalid_client_metadata-style errors, so a
client never gets registered with a config the server can't honour.

CreateInput / UpdateInput grow the same fields so the admin clients
API can manage encryption through the same path. The model row is
populated only when the caller actually set the field; missing fields
stay NULL (= no encryption).
OIDC Core §3.1.2.5 / §5.3.2 expect the discovery document to list the
algorithms a client may pick for ID token and UserInfo encryption.
The OIDC discovery now exposes:
- id_token_encryption_alg_values_supported
- id_token_encryption_enc_values_supported
- userinfo_encryption_alg_values_supported
- userinfo_encryption_enc_values_supported

Values come from the SupportedJWEAlgs / SupportedJWEEncs constants in
encryption.go so what we advertise and what we actually accept stay
in lockstep.

OAuth metadata (RFC 8414) intentionally does not get these fields — they
are OIDC-only and the OAuth metadata document is meant to be a strict
subset usable by pure OAuth resource servers.
Spins up a httptest JWKS endpoint, calls EncryptForClient against it,
then decrypts the resulting compact JWE with the RP's private key to
prove the wrapping is correct. Also verifies:
- compact serialization has 5 segments (4 dots)
- unsupported algorithms are rejected upfront
- empty jwks_uri is rejected upfront
- a JWKS that only contains use=sig keys is rejected
- ValidateJWEEncryptionPair matrix: both empty ok, alg-only fails,
  enc-only fails, both supported ok, unsupported alg/enc fail
test(client): DCR validation of encryption fields
All checks were successful
Tests / test (pull_request) Successful in 58s
85d27d81af
Covers validateEncryptionPair across the partial-config and
unsupported-value paths, plus a sanity guard that the two
supportedJWE{Algs,Encs} maps stay non-empty and include the OIDC
Core baseline (RSA-OAEP-256 + A256GCM).
nhpro merged commit fb504fe8c4 into master 2026-05-19 15:20:43 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
nhpro/orion-auth-backend!2
No description provided.