You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 acceptstoken_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.
authorization_code exchange with no client authentication at all → 200, 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
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_bearergrant (§2.1) already implements the assertion-validation machinery; this is the §2.2 client-auth flavor of the same thing.
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.
Validate token_endpoint_auth_method at registration against an allow-list (today any string is stored verbatim).
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.)
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).
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), whileprivate_key_jwtandtls_client_authexist as standard methods.The actual problem: private_key_jwt is half-implemented
Client registration already accepts
token_endpoint_auth_method: private_key_jwtand stores key material (jwks/jwks_uri— seeCreateOAuthClientInput, internal/handler/oauth_client.go; the service stores the caller's auth method verbatim with no allow-list), but: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.confidential: false, token_endpoint_auth_method: private_key_jwt, jwks: {...}is stored asclient_type=publicwith no secret, andTokenEndpointAuthMethodis never consulted at auth time:authorization_codeexchange with no client authentication at all → 200, token issued (verifyConfidentialClientAuthtreats no-stored-secret as public-client-OK; same path gates refresh_token and CIBA redemption).client_idonly, no secret, no assertion → accepted as valid public-client auth (VerifyPresentedClientAuth).client_credentialswith 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.private_key_jwtin its enum; DCR (dynamic_registration.go) constrains toclient_secret_post/client_secret_basic.Proposed scope
client_assertion+client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-beareron /oauth2/token (and introspect/revoke), validated against the client's registeredjwks/jwks_uri— RFC 7523 §3 claims (iss=sub=client_id, aud=issuer, exp, jti single-use). Thejwt_bearergrant (§2.1) already implements the assertion-validation machinery; this is the §2.2 client-auth flavor of the same thing.token_endpoint_auth_method: aprivate_key_jwtclient 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.token_endpoint_auth_methodat registration against an allow-list (today any string is stored verbatim).private_key_jwtintoken_endpoint_auth_methods_supported/introspection_…/revocation_…only once enforced. (Note: metadata currently also advertisesclient_secret_basicfor the token endpoint, which has no Basic parsing — same fix family, can ride along.)private_key_jwtwithjwks/jwks_uriin the registration payload.Why this is worth doing (and tls_client_auth is not, yet)
private_key_jwtfits 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_jwtis skipped permanently (dropped in OAuth 2.1).