fix(security): harden OAuth flow defaults — PKCE, single-use refresh, drop implicit, validate state#120
fix(security): harden OAuth flow defaults — PKCE, single-use refresh, drop implicit, validate state#120camreeves wants to merge 3 commits into
Conversation
OmniAuth validates the OAuth2 `state` parameter on the callback as a defence against CSRF (RFC 6749 §10.12). The custom strategy was setting `provider_ignores_state = true` unconditionally — added originally to work around legacy providers that mishandle state in multi-factor or cross-domain flows — which disabled the protection for every authority. Switch the default to enabled and expose the legacy behaviour behind the `OAUTH_PROVIDER_IGNORES_STATE` env var so deployments that need it can opt back in explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two OAuth 2.1 alignment changes for the authorization server: * `force_pkce` enabled. Public clients are now required to send a `code_challenge` on /oauth/authorize and a matching `code_verifier` on /oauth/token (RFC 7636). Confidential clients are unaffected. * The OAuth 2.0 implicit grant is removed so `response_type=token` now returns `unsupported_response_type` (OAuth 2.1 §2.1.2). Note: we deliberately do NOT use the `implicit_oidc` grant-flow alias. doorkeeper-openid_connect registers it as an alias for ["implicit", "id_token", "id_token token"], and Doorkeeper's calculate_grant_flows expands aliases into the effective flow set — so listing `implicit_oidc` would silently re-enable the bare `implicit` flow and keep `response_type=token` working. The OpenID Connect identity-token flows are therefore listed explicitly (`id_token`, `id_token token`), which drops `implicit` while retaining OIDC support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Here's the pentest report this PR references: DataArt_McKinsey.Converge_PenetrationTesting_Report_v1.0_05152026.pdf |
OAuth client audit (McKinsey deployment) for
|
| Client | Auth flow | Verdict |
|---|---|---|
| Staff (SPA) | was implicit (response_type=token); now response_type=code + PKCE |
Safe. Fixed in the frontend (auth_type: 'auth_code'), deployed to UAT and verified (authorize uses code + code_challenge=S256, token exchanged with code_verifier, no token in the URL fragment). |
| Concierge (SPA) | same as Staff | Safe. Same fix, deployed to UAT. |
| Backoffice (SPA) | response_type=code + PKCE already (this is the client the PKCE-downgrade finding was tested against) |
Safe. |
| Booking panel / mobile (Flutter) | X-API-KEY on PlaceOS (no OAuth token flow at all); password + refresh_token grants only in legacy ACA-Engine mode |
Not affected. password/refresh_token are retained, and its refresh logic stores the rotated token, so the M3 single-use change is also handled. |
| Kiosk | a module inside the Staff app, sharing the same ts-client setup | Safe (covered by the Staff fix). |
| Drivers (mck-drivers) | server-side; the o365 driver uses client_credentials |
Not affected (client_credentials is retained). |
| analytics (Chronograf) | a registered OAuth app, but Chronograf is not deployed on this stack (/analytics/ returns 404 and it is absent from the EKSv2 charts) |
Moot. If it is ever deployed it would need PKCE, or to be registered as confidential: true, since Chronograf does not send a code_challenge. Worth removing the stale registration as cleanup. |
Note: every app is registered confidential: false (the PlaceOS default), so force_pkce applies to all of them. Staff/Concierge/Backoffice pass because they send PKCE; the panel and drivers don't use the authorization-code grant at all.
Critical deployment sequencing
The Staff/Concierge frontend change that moves them onto response_type=code + PKCE is the prerequisite for this PR. It is live on UAT, but the prod frontend is currently pinned (blocked by the levels regression). This backend change must not reach prod until the Staff/Concierge auth_type: 'auth_code' frontend build is also in prod, otherwise prod Staff/Concierge logins will start returning unsupported_response_type (and, once they fall back, PKCE-required).
Frontend fix for reference: mckinsey-converge 50a900628 (auth_type: 'auth_code') plus e267e5450 (clears the consumed OAuth callback params), both on uat-placeos.
M3 (single-use refresh) compatibility
The clients that use refresh tokens are the ts-client SPAs (Staff/Concierge/Backoffice) and the panel in legacy ACA mode. ts-client stores the rotated refresh_token from each token response, and the panel's refresh path does the same, so single-use refresh does not break them. Existing outstanding refresh tokens will still need the one-off data cleanup at prod-migration time (already on the list).
CI note
The red checks on this PR (security, style, tests) are pre-existing repo-infrastructure failures, the jobs use stale third-party actions (Ruby 2.6 images, missing docker-compose/make) and fail on every master commit too. The meaningful Build job passes.
When the `oauth_access_tokens.previous_refresh_token` column is present, Doorkeeper switches the refresh flow from immediate to deferred revocation (lib/doorkeeper/oauth/refresh_token_request.rb:37): the caller-supplied refresh token is left valid, the new pair stores its value in `previous_refresh_token`, and the old token is only revoked later when the application invokes `revoke_previous_refresh_token!` on the new access token. Until that follow-up call lands, the same refresh token can mint additional token pairs — i.e. refresh tokens are not single-use, which contradicts RFC 6819 §5.2.2.3. Drop the column. `refresh_token_revoked_on_use?` now returns false, so `before_successful_response` revokes the old refresh token in the same transaction that mints the new pair. The explicit `revoke_previous_refresh_token!` call in AuthoritiesController#current is no longer needed and is removed. Trade-off: the deferred-revoke pattern existed so a client that lost the response to a refresh request could safely retry with the old token. Single-use refresh removes that grace window; clients that hit a network failure mid-refresh re-authenticate, which is the recommended posture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a9bce2f to
6c18f8d
Compare
|
Heads up on a force-push I just made to this branch while re-reviewing before requesting review — I amended the PKCE/implicit commit ( What was wrong: the commit dropped bare The fix: list the OpenID Connect identity-token flows explicitly instead of the alias: grant_flows ["authorization_code", "client_credentials", "password", "id_token", "id_token token"]This drops One open question for you: I kept |
Summary
Four small changes to align the OAuth flow with OAuth 2.1 / RFC 6819 / RFC 7636 and close a CSRF gap in the OmniAuth strategy. Each change is in its own commit so reviewers can take them independently.
M1 — validate the state parameter by default
lib/omniauth/strategies/generic_oauth.rbwas settingprovider_ignores_state = trueunconditionally. The original commit (3ae08a5) added it as a workaround for legacy providers that mishandlestatein multi-factor flows, but it disables OmniAuth's default CSRF protection (RFC 6749 §10.12) for every authority.Switch the default to enabled and expose the legacy behaviour as opt-in via the env var
OAUTH_PROVIDER_IGNORES_STATE=truefor deployments that still need it.M2 + M4 — require PKCE and drop the implicit grant flow
Two changes in
config/initializers/doorkeeper.rb:force_pkce. Public clients using the authorization_code grant must now send acode_challengeon/oauth/authorizeand a matchingcode_verifieron/oauth/token(RFC 7636). Confidential clients are unaffected.implicitfromgrant_flows. OAuth 2.1 §2.1.2 drops the flow;response_type=tokennow returnsunsupported_response_type.implicit_oidcis retained — it's defined by a different spec and still used by some identity-token consumers.M3 — revoke refresh tokens on use
With
oauth_access_tokens.previous_refresh_tokenpresent, Doorkeeper takes the deferred-revoke path atlib/doorkeeper/oauth/refresh_token_request.rb:37: the caller-supplied refresh token stays valid, the new pair stores its value inprevious_refresh_token, and the old token is only revoked later whenrevoke_previous_refresh_token!is invoked on the new access token. Until that follow-up call lands, the same refresh token can mint additional token pairs, which contradicts RFC 6819 §5.2.2.3 (single-use refresh).The migration drops the column.
refresh_token_revoked_on_use?then returns false, sobefore_successful_responserevokes the old refresh token in the same transaction that mints the new pair. The now-no-oprevoke_previous_refresh_token!call inAuthoritiesController#currentis removed.Trade-off: there's no longer a grace window for a client to retry with the same refresh token if it lost the response. The accepted behaviour is re-authentication, consistent with the RFC.
Backwards compatibility notes
force_pkce— rejects authorization_code requests from public clients that don't send PKCE. Confidential clients are unaffected. Deployments with non-PKCE public clients need to update the clients or mark them confidential.implicitfromgrant_flows—response_type=tokenrequests start returningunsupported_response_type.implicit_oidcis retained.previous_refresh_tokenvalue, so any in-flight refresh relying on the deferred-revoke grace window will need a re-auth instead. The migration is reversible.Test plan
response_type=tokenreturnsunsupported_response_type;/oauth/tokenwithoutcode_verifierfrom a public client returnsinvalid_request; second/oauth/tokencall with the same refresh token returnsinvalid_grant; OAuth callback withoutstatereturns CSRF error🤖 Generated with Claude Code