Repository navigation
Conversation
The stateless branch lives in AccessTokenMixin.find_or_create_for, but ClientCredentials::Creator calls create_for directly. With stateless_jwt_tokens enabled every client_credentials request still wrote an access token row. That is the grant where skipping the DB matters most for machine-to-machine APIs. Build a StatelessToken in the Creator when the option is set. Reuse is skipped as in find_or_create_for, since there is no row to reuse. revoke_previous_client_credentials_token keeps revoking tokens persisted before the option was turned on. The revocation mismatch between the stateless authentication path and the row-based /oauth/revoke endpoint (finding 2) is not addressed here. The client has no resource owner, so the stateless builder no longer records "NilClass" as resource_owner_type with a polymorphic resource owner. The stateless builder now hands the generator the same optional attributes as the persisted path: the polymorphic resource owner and every configured custom attribute. create_for forwarded them for client credentials, so moving the grant onto build_stateless_token would otherwise have dropped the claims a generator derives from them. ORM adapters that reimplement token creation (Mongoid, Sequel) do not define build_stateless_token. The Creator raises Errors::StatelessTokensUnsupported, answered as server_error by the token endpoint, instead of a NoMethodError. revoke_previous_client_credentials_token and revoke_previous_authorization_code_token promise a single valid token per client, which a stateless token cannot keep: only rows persisted before the option was enabled are revoked. Configuration now warns about the combination the way it already does for reuse_access_token. The capability check runs before any reuse or revocation lookup. Those lookups call extract_custom_attributes and matching_token_for with custom_attributes:, which the Mongoid and Sequel mixins do not define either, so with revoke_previous_client_credentials_token enabled the lookup raised its NoMethodError before the actionable error was reached.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Stateless issuance currently breaks custom attributes, external ORM adapters, and the configured single-token revocation guarantee.
4 open findings
What changed in this PR
Moves client-credentials issuance onto Doorkeeper’s stateless JWT path, addressing issue #1600’s database-write gap.
Changes:
- Skips persistence and token reuse for stateless client credentials.
- Preserves cleanup of previously persisted tokens.
- Omits
resource_owner_typewhen no resource owner exists.
| File | Description |
|---|---|
lib/doorkeeper/oauth/client_credentials/creator.rb |
Selects stateless or persisted issuance. |
lib/doorkeeper/models/access_token_mixin.rb |
Prevents a "NilClass" owner type claim. |
spec/lib/oauth/client_credentials/creator_spec.rb |
Tests stateless creator behavior. |
spec/lib/oauth/stateless_token_spec.rb |
Tests absent-owner claims. |
spec/requests/flows/client_credentials_spec.rb |
Adds end-to-end stateless flow coverage. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
55728
force-pushed
the
fix/stateless-jwt-client-credentials
branch
from
October 12, 2026 09:06
6d613f4 to
425f6c6
Compare
55728
force-pushed
the
fix/stateless-jwt-client-credentials
branch
from
October 14, 2026 12:16
425f6c6 to
7ba54fe
Compare
55728
force-pushed
the
fix/stateless-jwt-client-credentials
branch
from
October 16, 2026 13:49
7ba54fe to
5995665
Compare
| application: application, | ||
| expires_in: expires_in, | ||
| created_at: iat, | ||
| stateless_generator_attributes(claims, resource_owner, token_attributes).merge( |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Refs #1600 (finding 3 in #1600 (comment)).
Problem
The stateless branch lives in
AccessTokenMixin.find_or_create_for, butClientCredentials::Creator#callcallscreate_fordirectly. Withstateless_jwt_tokensenabled, everyclient_credentialsrequest still wrote anoauth_access_tokensrow and returned the row's token. That is the grant where skipping the DB matters most for machine-to-machine APIs, so the performance win of the option was not there for its main use case.A second, smaller gap becomes reachable once the Creator takes the stateless path:
build_stateless_tokenwroteresource_owner.class.nameinto theresource_owner_typeclaim wheneverpolymorphic_resource_owneris on. A client credentials token has no resource owner, so the claim would have read"NilClass".Change
Creator#callbuilds aStatelessTokenthroughbuild_stateless_tokenwhenstateless_jwt_tokensis set, and keeps callingcreate_forotherwise.reuse_access_tokenis skipped on the stateless path, the same wayfind_or_create_forskips it: there is no row to reuse.revoke_previous_client_credentials_tokenis unchanged. It still looks up and revokes a matching token that was persisted before the option was turned on, so the switch-over does not leave old rows active.build_stateless_tokenomitsresource_owner_typewhen there is no resource owner, instead of recording"NilClass".Following the Copilot review:
build_stateless_tokenhands the generator the same optional attributes as the persisted path (#attributes_for_token_generator):resource_ownerwhenpolymorphic_resource_owneris on, and every configured custom attribute.create_foralready forwarded them for client credentials, so moving the grant onto the stateless builder would otherwise have dropped the claims a generator derives from them.build_stateless_token. The Creator raisesErrors::StatelessTokensUnsupported, answered asserver_errorby the token endpoint likeMissingResourceColumn, instead of aNoMethodError.revoke_previous_client_credentials_tokenandrevoke_previous_authorization_code_tokenpromise a single valid token per client, which a stateless token cannot keep: only rows persisted before the option was enabled are revoked. Configuration now warns about the combination at boot, the way it already does forreuse_access_token.extract_custom_attributes/matching_token_for(custom_attributes:), which the Mongoid and Sequel mixins do not define either, so withrevoke_previous_client_credentials_tokenthe lookup'sNoMethodErrorused to win over the actionable error.build_stateless_tokentreats an explicitexpires_in: nilas a non-expiring token, matchingcreate_for. The||fallback used to replace it withaccess_token_expires_in; the fix usesfetchwith a block and covers all grants through the shared builder.build_stateless_tokenlooks up custom attributes with indifferent access, so string-configured names fromcustom_access_token_attributesare preserved in claims and generator attributes.Note
Out of scope
Finding 2 from the issue comment is not addressed here.
Token.authenticateon the stateless path never reads the DB, while/oauth/revokeand/oauth/introspectlook tokens up by row. A token that has a row (one issued before the option was enabled) can therefore be "revoked" with a 200 and still authenticate. Whether stateless tokens should answerunsupported_token_typeon those endpoints, or the authentication path should gain a DB fallback, is the design question (Q2) left open in the issue. This PR only movesclient_credentialsonto the same stateless path the other grants already use.The RFC 8707
resourcevalue is still not forwarded to the generator or recorded asaudon the stateless path; that is a separate follow-up.