Skip to content

Require confidential clients to authenticate on the authorization_code grant - #83

Open
roborourke wants to merge 3 commits into
WP-API:mainfrom
humanmade:roborourke/Fix-get_type-private-auth-code-path-check
Open

roborourke wants to merge 3 commits into
WP-API:mainfrom
humanmade:roborourke/Fix-get_type-private-auth-code-path-check

Conversation

@roborourke

Copy link
Copy Markdown
Contributor

Builds on #36. Closes #27.

The admin form asks if a client is Private or Public. Nothing ever read the answer. get_type() had one caller: the code that fills that same form back in.

So the authorization_code grant never checked the client. It looked up the client ID and the code, then issued a token. A client_secret sent by the client was ignored. Anyone with a stolen code could swap it for a token.

This adds Client::requires_secret() from #36 and makes the token endpoint act on it.

What changed:

  • Private clients must now send their secret to swap a code for a token. The check runs before the code is looked up, so a failed attempt can't consume it.
  • Public clients still work with no secret. Only clients an admin marked Private are affected. Clients with no type set, or any other type, are left alone.
  • Failures reuse the 401 invalid_client error the client_credentials grant already returns. It doesn't say which half of the credentials was wrong.
  • Both grants now send a WWW-Authenticate header when the client used the Authorization header. RFC 6749 section 5.2 asks for this.
  • A Basic header secret is now read even when client_id came from the body, as long as the header names the same client.
  • check_secret() and is_client_credentials_enabled() move onto ClientInterface. PersonalClient had neither, so client_credentials with client_id=__personal_access_token fataled. That was an unauthenticated 500.

Breaking change: a Private client that never sent its secret will now get a 401. Filter oauth2.client.requires_secret to control this per client.

Note for reviewers: adding methods to ClientInterface breaks any third-party class that implements it. Those classes already fatal on the client_credentials path today.

23 new tests. 156 pass on single site and multisite, phpcs clean.

🤖 Generated with Claude Code

roborourke and others added 3 commits September 17, 2026 15:00
The token endpoint calls check_secret() and is_client_credentials_enabled()
on whatever get_client() returns, but neither method was on ClientInterface
and PersonalClient implemented neither. A POST to /oauth2/access_token with
grant_type=client_credentials and client_id=__personal_access_token reached
handle_client_credentials(), called an undefined method on the singleton and
fataled. That is an unauthenticated 500 on a public endpoint.

Put both methods on the interface, along with requires_secret() which the
authorization_code grant needs next, so the endpoint can rely on them. Give
PersonalClient implementations that all return false: it has no secret, it
is issued to a user rather than a client, and it must never authenticate as
one.

Adding methods to a published interface breaks any third-party class that
implements it. That is the lesser evil here, since such a class already
fatals on this path; failing at declaration time is louder but honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The admin form has always asked whether a client is Private or Public, and
described Private with the RFC 6749 section 2.1 wording for a confidential
client. Nothing ever read the answer back: get_type() had exactly one
caller, the code that re-populates that same form.

Expose the answer as requires_secret(), taken from rmccue's WIP in WP-API#36.
Only an explicit 'private' counts. Clients created before the meta existed
return '', and the test suite's own helper stores 'web', so keying off
anything looser would lock out installs that never made the choice.

Make it filterable on 'oauth2.client.requires_secret' so a site can demand
authentication from clients the admin never marked Private, or exempt one
client while an integration is updated. Enforcing this is a breaking change
for anyone whose Private client never sent its secret, and the escape hatch
should land with it rather than after the first support ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grant resolved client_id and code, then went straight to validating the
code and issuing a token. A client_secret sent in the body was accepted as a
request parameter and never read. Anyone holding a leaked or intercepted
code could redeem it without the secret, which is what RFC 6749 section
4.1.3 requires the server to prevent for a confidential client.

Check the secret when the client requires one, before the code is looked up,
so a failed attempt cannot consume or probe a code. Public clients still
exchange codes with no secret, which section 4.1.3 also allows, so this only
bites clients an admin explicitly marked Private.

A Private client whose stored secret is empty now always fails. That is a
broken registration rather than a supported configuration, and failing it
closed beats hash_equals( '', '' ) returning true for an empty submission.

Reuse the error the client_credentials grant already returns rather than
adding a third failure shape: one invalid_client code, 401, and no hint
about which half of the credentials was wrong. Both paths now also send
WWW-Authenticate when the client authenticated with the Authorization
header, per section 5.2. WP_Error cannot carry a header, so those responses
go through rest_convert_error_to_response() to get one.

Finally, read the Basic header for a secret even when client_id arrived in
the body, provided the header names the same client. Before, the header was
only consulted when client_id was absent, so a client splitting its
credentials across the two would have been rejected for sending no secret
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support client secrets, and require them if in confidential mode

1 participant