Repository navigation
feat(token-handler): distinguish public and confidential clients #81 - #457
dsschiramm wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the token endpoint client-authentication behavior to distinguish public vs confidential OAuth clients (RFC 6749 §4.1.3), allowing public clients to omit client_secret while keeping a fail-closed default for clients without an explicit type.
Changes:
- Move the “client_secret required” decision to after
model.getClient()so it can be based onclient.type. - Extend the model/client contract to include
type?: 'public' | 'confidential'(defaulting to confidential behavior when unset). - Add/adjust unit and integration tests covering public vs confidential clients with missing
client_secret.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/handlers/token-handler.js | Defers secret requirement check until after client lookup; adds client.type handling. |
| lib/model.js | Documents the new ClientData.type attribute. |
| index.d.ts | Adds type?: 'public' | 'confidential' to the Client TypeScript interface. |
| test/integration/handlers/token-handler_test.js | Adds integration coverage for public/confidential behavior when client_secret is omitted. |
| test/unit/handlers/token-handler_test.js | Adds a unit assertion that model.getClient() is called with null when the secret is omitted. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }; | ||
| } | ||
|
|
||
| if (pkce.isPKCERequest({ grantType, codeVerifier })) { |
There was a problem hiding this comment.
Can you please elaborate why this is now fully omitted? The role of PKCE is especially to protect public clients that cannot safely keep a secret.
There was a problem hiding this comment.
The !isPkce condition has been relocated. Previously, getClientCredentials had two early-return flows (one for PKCE requests and another for !isClientAuthenticationRequired) that both returned { clientId } without the secret. I consolidated both into a single return, because the decision regarding whether the secret is required or if the PKCE/public-client status excuse it, now take place in getClient after model.getClient() executes, since that's the only place client.type (public vs confidential) is known. Therefore, the check has moved but has not disappeared: getClient still evaluates isClientAuthenticationRequired(grantType, client) && !credentials.clientSecret && !isPkce before accepting a client without a secret, and isClientAuthenticationRequired checks if client is public.
There was a problem hiding this comment.
This change in the validation location is also the reason why I unified the error with the !client case: both throw the same InvalidClientError('Invalid client: client is invalid'). Otherwise a client_id that exists but lacks its required secret would be distinguishable from one that doesn't exist.
|
I'm generally positive for this but we need to keep a close eye on the PKCE. What do you think @dhensby? |
|
hey @dsschiramm I have made my agents to do a review with focus on edge cases and exploiting existing code to obtain token by circumventing current guards. Here is the review: I recommend requesting changes before merging. The direction is sound, but the implementation applies the public-client exemption too broadly and leaves the confidential-client/PKCE distinction incomplete. I reviewed commit 1. High priority: public clients can use Location: if (client && client.type === 'public') return false;This exemption applies to every grant type. If a model returns: { id: 'example', type: 'public', grants: ['client_credentials'] }the token endpoint can issue an access token using only The condition requires the public client to have that grant configured; it does not affect every public client automatically. Nevertheless, the library now explicitly knows the client is public and still permits a prohibited combination. RFC 6749 §4.4 restricts this grant to confidential clients. rfc-editor.org Requested change: explicitly reject 2. High priority: The same early return executes before checking requireClientAuthentication: { authorization_code: true }does not require authentication when the model returns This also exposes a problem with the PR’s interpretation of §4.1.3: authentication is required for confidential clients and for clients issued credentials or assigned other authentication requirements. The section also requires validation when client authentication is supplied. “Public” therefore cannot universally mean “ignore authentication requirements.” rfc-editor.org Requested change: define and test the precedence between client type, registered authentication requirements, and explicit grant-level configuration. The PR description’s example model also skips secret validation for every public client: if (client.type !== 'public' && client.secret !== clientSecret) return null;That example needs qualification or correction: it can silently accept incorrect supplied credentials. The library delegates credential verification to the model, so the model contract matters here. 3. Important existing gap: PKCE still bypasses the confidential-client secret check Location: if (this.isClientAuthenticationRequired(grantType, client)
&& !credentials.clientSecret
&& !isPkce) {A confidential client with valid PKCE can still receive tokens without a secret if This behavior predates the PR, and the existing model documentation explicitly makes applications responsible for rejecting such confidential clients. It is therefore not a newly introduced, unconditional authentication bypass. However, it undermines the proposed promise that confidential clients default to requiring authentication. PKCE proves possession of the verifier for a particular authorization transaction; it does not satisfy the confidential client’s separate authentication requirement. rfc-editor.org Requested change: use the new client distinction to separate authentication policy from PKCE validation. For explicitly confidential clients, PKCE must not waive authentication. Handle legacy clients without 4. Public-client support should address PKCE enforcement With the default configuration, I also reproduced authorization-code token issuance for a public client without either a secret or PKCE. That is not, by itself, a violation of the original §4.1.3. However, RFC 9700 §2.1.1 now requires public clients to use PKCE for authorization-code flows. datatracker.ietf.org The project already has Compatibility and documentation Two smaller changes also deserve attention:
Tests I would require before approval The added tests check client retrieval, but do not establish the behavior of a complete token exchange.
Moving the decision until after model lookup is sensible, and obtaining I also have them generated the respective tests and can also work on a fix for them. If you add me to your fork repository then I can directly commit the tests and the fixes. |
|
Hi @jankapunkt, thank you for the review; I’ve made adjustments based on the points you mentioned. 1. Public clients and 2. Precedence
A supplied secret is always passed to 3. PKCE no longer waives the secret for confidential clients 4. PKCE for public clients Compatibility and docs
Tests
The full suite passes. I think this covers them, but I'm happy to adjust anything you'd like to see done differently. |
dhensby
left a comment
There was a problem hiding this comment.
Thanks for the update @dsschiramm, and for working through Jan's review so carefully. Jan's points look addressed to me:
- public clients can't use
client_credentials - an explicit
requireClientAuthenticationentry wins - with
type: 'confidential', PKCE no longer stands in for the secret
The end-to-end tests through TokenHandler.handle() for every row of his table are great.
On Jan's question from July: I agree PKCE is additive. It binds the code to the request that obtained it, but it's never a substitute for client authentication, which is exactly the public/confidential question this PR is about.
I re-tested the current head (cccaf7a) against master, and a few things aren't covered yet.
1. Clients without type don't behave quite like master
The description and your reply say untyped clients should behave as before. Two differences:
Requests without a secret now reach model.getClient() instead of being rejected up front. On master, a secretless password, client_credentials, refresh_token or non-PKCE authorization_code request gets 400 invalid_client before the model is called. Now getClient(id, null) is called first (token-handler.js:139; the early rejection is gone from getClientCredentials() at :195-199). A model that compares the secret without a null guard, e.g. crypto.timingSafeEqual(Buffer.from(secret), …) or bcrypt.compare(secret, hash), now throws, and the client gets 503 server_error instead of 400 invalid_client.
Could you keep the up-front rejection whenever nothing could make the request acceptable? That is: no secret, no explicit requireClientAuthentication[grant] entry, and the request isn't authorization_code + code_verifier or refresh_token.
requireClientAuthentication: { authorization_code: true } now means something different for untyped clients. On master, an explicit true behaved exactly like leaving the key unset. Now the early return at :317-318 skips the legacy PKCE waiver, so an untyped client doing PKCE without a secret gets invalid_client where master issued a token. I think the stricter behaviour is what someone writing true wants, so I'm happy to keep it. It does need a test with an untyped client (the existing explicit-true tests only use public clients) and a mention in the docs.
2. "Public clients must use PKCE" only holds when they don't send a secret
The rule lives inside the secret-presence check (:153, :322). So a type: 'public' client that sends any client_secret, or any public client under { authorization_code: false }, can still redeem a code that was issued without a code_challenge, with no verifier. Enforcing it per client is a one-liner at :239:
requirePKCE: this.requirePKCE === true || client.type === 'public',verifyPKCE then rejects with invalid_grant ("issued without a code_challenge"), which is also a clearer error.
3. Unrecognised type values silently get the legacy behaviour
'Confidential', 'CONFIDENTIAL' (e.g. a DB enum passed straight through) or '' fall through to the untyped path, so PKCE still waives the secret. Could you throw a ServerError when type is set but isn't 'public' or 'confidential', the way :145-151 does for a bad grants?
4. Docs and types
The pkce.md rewrite is much better, but a few places still describe the old model:
lib/server.js:43and:187, which generatedocs/api/server.md, andindex.d.ts:235-238still say per-client enforcement must happen ingetClient().docs/api/needs regenerating (npm run docs:api).- The PR description still says untyped clients are fail-closed with "no behavior change", and still shows the example model Jan flagged.
5. Commit history
We merge with merge commits, so the branch history lands on master as-is. Could you rebase onto master and squash into one commit, or a few atomic ones, following Conventional Commits? Right now there are eight merges from master and a "Potential fix for pull request finding" commit, which commitlint will reject once #335 lands. Please also leave #81 out of the commit subject and link it from the description instead.
Jan and I will also confirm how this lines up with #453, which reworks the same client-authentication code. That doesn't change anything above. Thanks again, the core of this is good.
Add an optional `type` ('public' | 'confidential') to the client
returned by model.getClient(). It decides whether a client_secret is
required when requireClientAuthentication has no entry for the grant.
- confidential clients and clients without type always need a secret;
PKCE is additive
- public clients must use PKCE for authorization_code, may use
refresh_token without a secret and cannot use client_credentials
- any other type value throws a ServerError
- requests that no client type could accept are rejected before
model.getClient() is called
BREAKING CHANGE: clients without `type` that redeemed PKCE codes without
a client_secret are now rejected. Return `type: 'public'` from
model.getClient() for those clients.
41a6b9b to
a55bf26
Compare
|
Thanks @dhensby for the detailed re-test, and to you and @jankapunkt for your patience. I agree that PKCE is additive, and I took that further than you suggested: clients without 1. Clients without
2. PKCE for public clients 3. Unknown 4. Docs and types 5. Commit history |
Summary
Closes the gap described in #81: the library had no distinction between public and confidential clients (RFC 6749 §2.1). Whether a
client_secretwas required depended only on thegrant_typeand therequireClientAuthenticationoption, not on the client. A public client could therefore be forced to send a secret it cannot keep, and a confidential client could skip its secret on anyauthorization_coderequest that used PKCE. This PR adds an optionaltypeattribute ('public' | 'confidential') to the client returned bymodel.getClient(). The token endpoint decides from that type whether a secret is required. An explicitrequireClientAuthenticationentry for a grant type still takes precedence for all clients.Confidential clients, and clients without
type, must authenticate with their secret on every token request. PKCE is additive and never replaces the secret. Public clients authenticate without a secret. They must use PKCE forauthorization_code, even if they send a secret orrequireClientAuthenticationwaives it. They may userefresh_tokenwithout a secret, unlessrequireClientAuthentication.refresh_tokenis set. Theclient_credentialsgrant type cannot be used with public clients.Obs.: Unified the error message to prevent leaking whether a client exists. Both unknown clients and clients missing their required secret return the same InvalidClientError("Invalid client: client is invalid").
Obs.: Requests without a secret that no client type could accept are still rejected before
model.getClient()is called, as before. Whenmodel.getClient()is called without a secret, it receivesnullinstead ofundefined.Obs.: A
typeother than'public'or'confidential'(e.g.'Confidential'or'') throws a ServerError instead of silently falling back.nullcounts as no type.Breaking change: clients without
typeare treated as confidential. Previously, anauthorization_coderequest with acode_verifierskipped the secret check for every client. Such requests are now rejected without a secret. Migration: returntype: 'public'frommodel.getClient()for clients that use PKCE without a secret (SPAs, native apps).Linked issue(s)
#81
Involved parts of the project
Added tests?
OAuth2 standard
Reproduction