Skip to content

add dpop (rfc 9449) support#1794

Open
gkemmey wants to merge 12 commits into
doorkeeper-gem:mainfrom
gkemmey:dpop
Open

add dpop (rfc 9449) support#1794
gkemmey wants to merge 12 commits into
doorkeeper-gem:mainfrom
gkemmey:dpop

Conversation

@gkemmey

@gkemmey gkemmey commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

summary

adds a minimally spec-compliant implementation of oauth 2.0 demonstrating proof of possession (dpop).

closes #1655

overview

dpop is a sender-constraining mechanism that binds access tokens to a client's cryptographic key pair. unlike bearer tokens, a dpop-bound token can't be used by an attacker who intercepts it -- they'd also need the private key that corresponds to the public key presented when the token was issued.

this covers both halves of doorkeeper: issuing dpop-bound access tokens (authorization server) and enforcing the key binding when authenticating (resource server).

the feature is fully opt-in. nothing changes for existing installations unless you run rails generate doorkeeper:dpop and migrate.

how it works

on the authorization server side, when a token request includes a DPoP header or force_dpop? is configured, the proof is validated and the resulting access token is bound to the public key via its jwk sha-256 thumbprint.

on the resource server side, when a dpop-bound token is presented or required (by either configuration or the endpoint), the accompanying proof must be valid and its key must match the one the token was originally bound to.

refresh token flow

the refresh token flow follows the spec's distinction between confidential and public clients.

a confidential client that refreshes a dpop-bound token can optionally include a new dpop proof:

  • if it does, the new token is bound to that proof's key
  • if it doesn't, the binding carries forward from the original token

a public client must always include a valid dpop proof whose key matches the original binding.

force_dpop vs access_token_methods == %i[from_dpop_authorization]

when doorkeeper is acting as an authorization server, force_dpop requires every token request to include a valid dpop proof and ensures the minted access token is bound to that proof's public key.

when acting as a resource server, dpop is considered required if from_dpop_authorization is the only configured access token method, or if the endpoint has explicitly required it via doorkeeper_authorize!(dpop: :required).

www-authenticate headers

the error response www-authenticate headers are context-sensitive. when dpop is required, only the dpop scheme is advertised. when both bearer and dpop are supported, the header includes both schemes along with the supported algorithms. when a specific access token method was used to authenticate, the header reflects that method. this follows the guidance in the spec around telling clients what the server actually accepts.

other information

optional specifications this skips

this intentionally omits a few optional parts of the spec:

  • there's no jti tracking to prevent dpop proof replays (section 11.1)
  • no server-provided nonces to prevent pre-generated proofs (section 8), and
  • no authorization code binding to a dpop key (section 10)

all of these would be reasonable follow-ups but aren't required for a baseline implementation, and the first two each introduce storage / coordination concerns that deserve their own consideration.

stores dpop_jkt on the oauth_access_tokens table

the spec describes storing the bound public key in the token itself when the format allows for it (i.e., it's a jwt). however, doorkeeper doesn't natively issue structured tokens, and storing the thumbprint in the database is in keeping with the surrounding gem's approach.

the spec allows for other association methods in section 6, but doesn't describe any beyond the jwt-based approach. that said, at some point it may make sense to allow the dpop binding to be stored directly in the token if doorkeeper-jwt is being used.

jwt dependency

this adds the jwt gem as a runtime dependency (not in the gemspec, required when the dpop proof class loads) to validate and decode dpop proofs.

also includes a `doorkeeper:dpop` generator for installing the migration and
the results of installing it in the dummy app.

the migration indexes `dpop_jkt` to support a query like

```sql
update oauth_access_tokens set revoked_at = now() where dpop_jkt = '...';
```

in the event a client's dpop key is compromised.
adds configuration option to `force_dpop` on access token requests and an
accompanying `force_dpop?` helper.

adds configuration options for `dpop_signature_algorithms` and `dpop_iat_leeway`.

considers dpop supported when the `access_token_model` has a `dpop_jkt` column.
when supported, prepends `from_dpop_authorization` to the default
`access_token_methods`.
…op proof header

this introduces a model for working with dpop proofs so we can validate the
dpop header and derive the public key thumbprint for either binding or
token validation.

additionally, this adds the same `jwt` gem the latest release (0.4.2) of
`doorkeeper-jwt` [uses][] as a runtime dependency. we ensure it's present when
`OAuth::DPoPProof` autoloads. at some point, it may make sense to ensure the
DPoP binding can be stored directly in the token if `doorkeeper-jwt` is being
used.

[uses]: https://redirect.github.com/doorkeeper-gem/doorkeeper-jwt/blob/v0.4.2/doorkeeper-jwt.gemspec#L25
…equest`

this centralizes request parameter handling in `BaseRequest` and updates the
oauth request classes to forward a `parameters` keyword argument instead of
a positional hash argument.

this creates space to pass through other request details.
…uer`

`BaseRequest` mixes in `Validations`, and `ClientCredentialsRequest` previously
short-circuited that flow by overriding `valid?`. it's more correct, and better
aligned with the public api `Validations` exposes, to override `error` and
`validate` to involve the `issuer` and leave `valid?` unchanged.

additionally, `Validations#valid?` now relies on the `error` reader method,
instead of using `@error` directly. that creates space for including classes to
override `error` and compose additional error sources.

while there are no validations defined on `ClientCredentialsRequest` itself,
this leaves room for them to exist and cooperate with the validations the
`issuer` invokes (through its `validator`).
oauth extension error code defined in [rfc 9449, 12.2][]

[rfc 9449, 12.2]: https://datatracker.ietf.org/doc/html/rfc9449#section-12.2
…word flows

this builds an `OAuth::DPoPProof` from the request in `Server` and injects it
into each `OAuth::XRequest` class. when the access token model supports dpop,
each request validates the dpop proof when dpop is required or a proof is
present; otherwise validation is skipped.

when dpop is supported and a valid dpop proof is provided, the access token is
bound to the proof key by persisting the jwk thumbprint as `dpop_jkt` on the
token, and the token response returns `token_type=DPoP`.

when dpop is not supported or a dpop proof is not provided, dpop proof
validation is skipped and bearer tokens are issued.

when dpop is supported and a provided proof is invalid, token requests are
invalidated.

when dpop is required (`force_dpop?`), token requests without a dpop proof
are invalidated.

note: the refresh token flow will be handled in a separate commit.

note: the implicit flow is unsupported. there's [an expired internet draft][]
that allows for passing a dpop proof via a query param, but it's not a part of
rfc 9449.

[an expired internet draft]: https://datatracker.ietf.org/doc/html/draft-jones-oauth-dpop-implicit-00
this injects an `OAuth::DPoPProof` into the `RefreshTokenRequest` and applies
dpop binding / validation when the access token model supports dpop.

when dpop is supported and a valid dpop proof is provided, refreshed access
tokens are bound to the proof key by persisting the jwk thumbprint as `dpop_jkt`
on the new token, and the token response returns `token_type=DPoP`.

refreshing a token that already uses dpop is handled differently based on client
type:

- confidential clients can refresh without presenting a dpop proof, because
client authentication effectively sender-constrains the refresh request. the
new access token remains dpop-bound, preserving the existing `dpop_jkt`, while
still allowing an authenticated refresh to rotate the binding when a new valid
dpop proof is provided.

- public clients must prove possession on refresh when the refresh token is
already dpop-bound: the request must include a valid dpop proof and it must
match the existing binding; missing proofs or key mismatches invalidate the
request.

when dpop is not supported, dpop proof validation is skipped and bearer tokens
are issued.

finally, this commit supports upgrading an unbound access token to a dpop-bound
access token on refresh when a valid proof is provided. when dpop is required
(`force_dpop?`), refresh requests that would otherwise mint an unbound access
token (i.e. without a dpop proof) are invalidated.
…s token

when the introspected token uses dpop, the response now includes a `cnf` object
with the token's jwk thumbprint (`jkt`), per [rfc 9449, section 6.2][].

[rfc 9449, section 6.2]: https://datatracker.ietf.org/doc/html/rfc9449#name-jwk-thumbprint-confirmation-
…rom `OAuthToken`

in anticipation of needing the extra authentication details, adds an
`authenticate3` method that returns a triplet: the `access_token_method`, the
`plaintext_token`, and the `access_token` record. the public `authenticate`
method that just returns the `access_token` is preserved, and is implemented
in terms of `authenticate3`.

this is meant to loosely mirror ruby's Open3 apis.
…en rails and grape

removes the grape-specific `doorkeeper_token` implementation and instead relies
on the shared rails helpers (`Rails::Helpers` is included in `Grape::Helpers`).

introduces `__doorkeeper_request__` which can be overridden to customize what
gets passed into `OAuth::Token.authenticate`: rails returns its raw request,
while grape wraps its request in `AuthorizationDecorator`.
…cted resources

adds support to `doorkeeper_authorize!` for requiring dpop (`dpop: :required`).
the helper-level hook forces dpop on a specific endpoint regardless of global
configuration.

when dpop is required -- either because `:from_dpop_authorization` is the only
available access token method or via `dpop: :required` -- we only authenticate
via the dpop scheme; we always validate the dpop proof and the token's binding
to the proof's public key, per rfc 9449.

when dpop is optional, behavior is driven by Doorkeeper's configuration: if the
access token model reports dpop support (`dpop_supported?`), we accept either
bearer or dpop authentication. if the request uses the dpop scheme, we require
a valid proof and ensure the presented token is bound to that proof, and we
reject downgrade attempts. if dpop is unsupported, we fall back to bearer
behavior.

for errors, this introduces `OAuth::InvalidDPoPProofResponse` for invalid /
missing proofs, and adds an `invalid_dpop_key_binding` `invalid_token` variant
for mismatched bindings. additionally, `www-authenticate` challenges are now
dependent on the `access_token_method` and whether dpop was required or
supported, so we pass those details through the error responses.

note: rfc 6750 discourages including an error code in `www-authenticate` when
the request includes no authentication information and rfc 9449 recommends
the same for dpop challenges. doorkeeper historically returned Bearer errors
in this case, so we keep that behavior for consistency while adding the
additional dpop challenge.
@guardrails

guardrails Bot commented Feb 26, 2026

Copy link
Copy Markdown

⚠️ We detected 1 security issue in this pull request:

Insecure Processing of Data (1)
Severity Details Docs
Medium Title: Potential XSS (unquoted template variable)
class EnableDpop < ActiveRecord::Migration<%= migration_version %>
📚

More info on how to fix Insecure Processing of Data in Ruby.


👉 Go to the dashboard for detailed results.

📥 Happy? Share your feedback with us.

@ThisIsMissEm

ThisIsMissEm commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

This seems to miss all dpop-nonce handling and any tracking of previously used jti values, which are crucial for preventing replay attacks with DPoP. Whilst you do note these as missing, DPoP without these doesn't really make much sense.

@gkemmey

gkemmey commented Mar 1, 2026

Copy link
Copy Markdown
Contributor Author

@ThisIsMissEm, thanks for looking! 🙏 those are worthwhile improvements, no doubt, but i think they can be handled separately. as is, we still get some (imperfect) defense against a stolen access token being used outside the issuing oauth application.

just to reiterate, the goal of the pr is minimal spec compliance. regarding replay, the rfc says servers "must only accept DPoP proofs for a limited time after their creation" (section 11.1). that's the baseline mitigation and this pr enforces a configurable iat window.

totally agree that additional measures like server-provided nonces and tracking previously-seen jti values can further reduce replay / pre-generation risks, but i'd prefer to add those as follow-ups.

@jeffsawatzky

jeffsawatzky commented Apr 12, 2026

Copy link
Copy Markdown

For those of us that are using doorkeeper-jwt and validating tokens at the resource server, we would have to handle tracking used jtis there anyway. And the iat provides reasonable protection as well.

So I would love to see this get included.

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 OAuth DPoP

3 participants