Skip to content

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

@rsharath

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions