Skip to content

private_key_jwt client authentication: enforce what registration already accepts (tls_client_auth deferred) #206

Description

@rsharath

Context

Raised by external review on #198 (#198 (comment)): the token/introspection/revocation endpoints support only client_secret_post + client_secret_basic (the latter added in #198), while private_key_jwt and tls_client_auth exist as standard methods.

The actual problem: private_key_jwt is half-implemented

Client registration already accepts token_endpoint_auth_method: private_key_jwt and stores key material (jwks / jwks_uri — see CreateOAuthClientInput, internal/handler/oauth_client.go; the service stores the caller's auth method verbatim with no allow-list), but:

  • No client_assertion / client_assertion_type (RFC 7523 §2.2) validation exists anywhere — a client registered for key-based auth cannot actually authenticate with its key.
  • Silent downgrade — empirically verified against the fix: security-review backlog — endpoint/grant client auth, policy ceiling, CIBA, SSRF, sweeps, config hardening #198 branch. A client registered with confidential: false, token_endpoint_auth_method: private_key_jwt, jwks: {...} is stored as client_type=public with no secret, and TokenEndpointAuthMethod is never consulted at auth time:
    • authorization_code exchange with no client authentication at all200, token issued (verifyConfidentialClientAuth treats no-stored-secret as public-client-OK; same path gates refresh_token and CIBA redemption).
    • Introspect/revoke with client_id only, no secret, no assertion → accepted as valid public-client auth (VerifyPresentedClientAuth).
    • client_credentials with no secret → 401 — this grant happens to fail closed (bcrypt compare against the empty stored hash always errors), so no token-issuance bypass there. The downgrade is real but does not extend to client_credentials.
  • Doc surface is inconsistent: oauth_client.go advertises private_key_jwt in its enum; DCR (dynamic_registration.go) constrains to client_secret_post/client_secret_basic.

Proposed scope

  1. Accept client_assertion + client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer on /oauth2/token (and introspect/revoke), validated against the client's registered jwks/jwks_uri — RFC 7523 §3 claims (iss=sub=client_id, aud=issuer, exp, jti single-use). The jwt_bearer grant (§2.1) already implements the assertion-validation machinery; this is the §2.2 client-auth flavor of the same thing.
  2. Enforce the registered token_endpoint_auth_method: a private_key_jwt client presenting a secret (or nothing) is rejected — closes the authcode/refresh/CIBA/introspect/revoke downgrade above. Until (1) lands, an interim hardening could simply reject auth-time use of clients whose registered method has no implementation.
  3. Validate token_endpoint_auth_method at registration against an allow-list (today any string is stored verbatim).
  4. Advertise private_key_jwt in token_endpoint_auth_methods_supported / introspection_… / revocation_… only once enforced. (Note: metadata currently also advertises client_secret_basic for the token endpoint, which has no Basic parsing — same fix family, can ride along.)
  5. Update DCR to optionally allow private_key_jwt with jwks/jwks_uri in the registration payload.

Why this is worth doing (and tls_client_auth is not, yet)

private_key_jwt fits ZeroID's no-shared-secrets-for-agents posture, reuses existing RFC 7523 machinery, and fixes an advertised-but-unenforced surface. It's also the direction OAuth 2.1 / FAPI point.

tls_client_auth (RFC 8705) is explicitly deferred: it requires an mTLS termination story (ZeroID deployments typically sit behind TLS-terminating proxies/ingress), certificate-bound access tokens, and deployment documentation — a feature program, not a gap fix, with no demand signal yet. Revisit when X.509 SVID / WIMSE mTLS work creates a concrete need. client_secret_jwt is skipped permanently (dropped in OAuth 2.1).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions