Skip to content

Issue stateless JWT tokens from the client credentials grant - #1972

Open
55728 wants to merge 1 commit into
stateless-jwt-tokenfrom
fix/stateless-jwt-client-credentials
Open

55728 wants to merge 1 commit into
stateless-jwt-tokenfrom
fix/stateless-jwt-client-credentials

Conversation

@55728

@55728 55728 commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Refs #1600 (finding 3 in #1600 (comment)).

Problem

The stateless branch lives in AccessTokenMixin.find_or_create_for, but ClientCredentials::Creator#call calls create_for directly. With stateless_jwt_tokens enabled, every client_credentials request still wrote an oauth_access_tokens row 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_token wrote resource_owner.class.name into the resource_owner_type claim whenever polymorphic_resource_owner is on. A client credentials token has no resource owner, so the claim would have read "NilClass".

Change

  • Creator#call builds a StatelessToken through build_stateless_token when stateless_jwt_tokens is set, and keeps calling create_for otherwise.
  • reuse_access_token is skipped on the stateless path, the same way find_or_create_for skips it: there is no row to reuse.
  • revoke_previous_client_credentials_token is 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_token omits resource_owner_type when there is no resource owner, instead of recording "NilClass".

Following the Copilot review:

  • build_stateless_token hands the generator the same optional attributes as the persisted path (#attributes_for_token_generator): resource_owner when polymorphic_resource_owner is on, and every configured custom attribute. create_for already forwarded them for client credentials, so moving the grant onto the stateless builder 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 like MissingResourceColumn, 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 at boot, the way it already does for reuse_access_token.
  • The capability check runs before the reuse and revocation lookups. Those lookups call extract_custom_attributes / matching_token_for(custom_attributes:), which the Mongoid and Sequel mixins do not define either, so with revoke_previous_client_credentials_token the lookup's NoMethodError used to win over the actionable error.
  • build_stateless_token treats an explicit expires_in: nil as a non-expiring token, matching create_for. The || fallback used to replace it with access_token_expires_in; the fix uses fetch with a block and covers all grants through the shared builder.
  • build_stateless_token looks up custom attributes with indifferent access, so string-configured names from custom_access_token_attributes are preserved in claims and generator attributes.

Note

Out of scope

Finding 2 from the issue comment is not addressed here. Token.authenticate on the stateless path never reads the DB, while /oauth/revoke and /oauth/introspect look 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 answer unsupported_token_type on those endpoints, or the authentication path should gain a DB fallback, is the design question (Q2) left open in the issue. This PR only moves client_credentials onto the same stateless path the other grants already use.

The RFC 8707 resource value is still not forwarded to the generator or recorded as aud on the stateless path; that is a separate follow-up.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_type when 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.

Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb Outdated
Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb Outdated
Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb
Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb
@55728
55728 force-pushed the fix/stateless-jwt-client-credentials branch from 6d613f4 to 425f6c6 Compare October 12, 2026 09:06
@nbulaj
nbulaj requested a balanced review from Copilot October 14, 2026 07:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The adapter capability check occurs after revocation lookup APIs that supported external adapters do not implement.

1 open finding
4 resolved since last review

🧠 Review effort: Balanced

Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb Outdated
@55728
55728 force-pushed the fix/stateless-jwt-client-credentials branch from 425f6c6 to 7ba54fe Compare October 14, 2026 12:16
@nbulaj
nbulaj requested a balanced review from Copilot October 15, 2026 13:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Stateless issuance loses string-configured custom attribute values and replaces explicitly non-expiring lifetimes with the global lifetime.

2 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread lib/doorkeeper/oauth/client_credentials/creator.rb
Comment thread lib/doorkeeper/models/access_token_mixin.rb
@55728
55728 force-pushed the fix/stateless-jwt-client-credentials branch from 7ba54fe to 5995665 Compare October 16, 2026 13:49
@nbulaj
nbulaj requested a balanced review from Copilot October 19, 2026 19:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The shared builder breaks keyword-only JWT generators on Ruby 3 by passing a positional Hash.

1 open issue
2 resolved since last review

Review effort: Balanced

application: application,
expires_in: expires_in,
created_at: iat,
stateless_generator_attributes(claims, resource_owner, token_attributes).merge(

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants