Repository navigation
Security: authenticate the resource owner at /oauth/authorize and /device, escape the consent and device pages - #21
Merged
eliasjpr merged 3 commits intoOct 25, 2026
Conversation
… the consent and device pages P0-1: /oauth/authorize read user_id and consent=approved from the query string, so anyone could obtain a code for any user (and an implicit token with any scope). The subject now comes only from Authly.config.authenticate_user, a host-supplied hook; until it is set the endpoint and /device fail closed (500). user_id/consent are ignored, consent is a POST carrying a signed, single-use, subject-bound token (Authly::FormToken), the scope is checked against the client before a code or an implicit token is issued, and PAR requests use only the pushed parameters. P0-2: client_id, scope, state, redirect_uri and user_code were interpolated into the consent and device pages unescaped. Everything is HTML-escaped, the redirect_uri is matched exactly before any rendering or redirect, and the pages send CSP, X-Frame-Options DENY, nosniff, no-store and no-referrer. /device takes the approving user from the hook too (P1: device approval trusted a form field), and device user codes use Random::Secure. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
…, form_post CSP - authenticate before loading (consuming) a pushed request_uri - device page shows client and scopes before approval; forms post to their own path - unknown client / unregistered redirect_uri / missing grant: 400 at the authorization endpoint - StateStore#consume returns Bool and is the only check; InMemoryStateStore is mutexed and sweeps expired entries - blank scope is invalid_scope, allowed_scopes? ignores empty tokens; subject from the hook is stripped - form_post.jwt page: no inline onload, CSP by script hash, DENY, no-store - README/CHANGELOG: multi-node secret_key and state_store, PKCE recommendation Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
- PARStore#consume returns Bool and is the only gate for a request_uri; InMemoryPARStore is mutexed - InMemoryStateStore caps live nonces at 10,000 and sweeps only from 256 entries up; valid? documented as unused - device error page link is relative; Clients#delete; specs remove the clients they add - CHANGELOG: custom PAR store contract, allowed_scopes? shared with the token endpoint Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HT5gvYGR83U2M4DyA6NA6f
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.
Summary
Fixes the two P0 findings of the security review (and the device-approval P1 that shares the root cause of P0-1).
P0-1: no authentication at the authorization endpoint
Before.
AuthorizationHandlerreaduser_idandconsent=approvedfrom the query string:The
statecheck only validated a value the same caller had stored; PAR also let the query string overridescope,redirect_uri,user_id.After. The subject comes only from an authenticated session supplied by the host, through one hook:
authenticate_useris nil,/oauth/authorizeand/deviceanswer 500 ("set Authly.config.authenticate_user") and issue nothing; query/body parameters are never trusted.user_idandconsent(query or body) are ignored. PAR: only the pushed parameters are used (query cannot add/override), anduser_id,consent,client_secretare no longer stored with a pushed request.redirect_urimatch against the client registration,response_type, grant type allowed, PKCE rules, and requested scope against the client's scopes (code and implicit paths;invalid_scoperedirect to the validated URI).Authly::Handlernow routesPOST /oauth/authorize). The consent page's form carries oneconsent_token: a signed (HMAC-SHA256 keyed fromsecret_key, purpose-bound), expiring token that binds client, redirect URI, scope, state, PKCE data, response type and the authenticated subject, plus a single-use nonce (consumed throughstate_store). The POST must come from the same signed-in subject, so it is also the anti-CSRF token: a token obtained by an attacker for their own session is rejected (403) when replayed against a victim, a tampered/foreign/expired/replayed token is rejected (400), and the POST cannot change scope or redirect URI.sub)./device: the approving user is the signed-in owner (hook), not auser_idform field; the form has a signed single-use token bound to that owner. Device user codes useRandom::Secure(wasRandom).P0-2: reflected XSS on the consent and device pages
Before.
GET /oauth/authorize?client_id="><script>...andGET /device?user_code="><script>...executed script on the authorization server's origin (client_id, scope, state, redirect_uri, user_code, and error messages were interpolated raw, in text andvalue="..."attributes).After. Every interpolated value goes through
HTML.escape(text and attribute contexts); unregisteredredirect_uri/unknownclient_idare rejected with a plain-text error that does not echo input, before any rendering; the consent, device and device error/success pages sendContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none',X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Cache-Control: no-store,Referrer-Policy: no-referrer. (Noform-actiondirective on purpose: Chrome applies it to the redirect that follows the consent POST.)Breaking change and migration
The hook is mandatory for
AuthorizationHandler/Authly::Handler(/oauth/authorize) andDeviceVerificationHandler(/device). Without it they answer 500. Setconfig.authenticate_user(see above). Clients/tests that sentconsent=approved&user_id=...must go through the consent page (GET, then POSTconsent_token+decision=approve|deny).stateis passed through to the client but no longer checked against a store the caller filled. Hosts that callAuthly.code(...)themselves are unaffected. Details are inCHANGELOG.md("Security", "Upgrading") andREADME.md("Authorization Endpoint").What authority must change
azutoolkit/authoritydoes not mountAuthly::Handler's authorization/device handlers: it has its ownAuthorize::NewEndpoint/CreateEndpoint(POST/authorize, user fromcurrent_session.user_id, consent stored viaConsent.grant!) and callsAuthly.code(...)directly. So: nothing is required to keep working. If authority wants Authly's built-in endpoints or its device page (DeviceAuthorizationHandleris used for device codes inClientProvider), it setsAuthly.config.authenticate_user = ->(ctx) { session user id from its session middleware }andlogin_urlto its login page, and mountsAuthly::Handler. Separately, authority's own authorize pages and/authorizePOST should get the same review (client/redirect/scope validation before rendering, escaping, CSRF token on the consent POST); not covered here.Tests
Failing specs first (see
spec/handlers/authorization_security_spec.cr, 33 examples, red against the old handlers: query bypass issued a code foradmin, implicit token without a user, unescaped<script>in the page), covering: fail-closed without the hook (code, implicit, POST, device), 401/login redirect,user_id/consentignored, consent POST without/with tampered/foreign/other-subject/replayed token, deny, scope escalation (code and implicit), unregistered redirect_uri / unknown client not reflected, XSS payloads in client_id/scope/state/user_code/error message, security headers, PAR override attempts, PAR not storinguser_id/consent, device approval with the authenticated subject only,FormTokenround trip/expiry/tamper. Existing handler and PAR specs were moved to the new flow (spec/support/authorization_flow.cr); the PAR "expired request_uri" spec had been passing for the wrong reason (invalid state) and now really expires the request.Results:
crystal spec455 examples, 0 failures;crystal tool format --check src specclean; Ameba (crystal run lib/ameba/bin/ameba.cr) 92 files, 0 failures; all on the CI image (crystallang/crystal, latest). On Crystal 1.19.1 the suite does not compile on master either (jarm_builder.cr:247:OpenSSL::Cipher#gcm_tagmissing;shard.ymlrequires >= 1.22.0), unrelated to this change.Review follow-ups (second commit)
PAR request_uri consumed only after sign-in (PAR + login_url works); device page shows client and scopes before approval; unknown client / unregistered redirect_uri / missing grant is 400;
StateStore#consumereturns Bool and is the only check (custom stores: breaking, documented),InMemoryStateStoremutexed and sweeping; multi-node (secret_key, sharedstate_store) and PKCE notes in the README; subject stripped; forms post to their own path; blank scope isinvalid_scope,allowed_scopes?ignores doubled spaces;form_post.jwtpage under a script-hash CSP with no inline handler.Not in this PR (remaining authly findings from the review)
device_code subject (
DeviceCodehas no subject), approval repeatable, attempt limit; JARM key separationP1: refresh tokens: no revocation /
token_type/ client binding check, rotation is a no-op, refreshed tokens lose scope, no reuse detectionP1: device flow: per-code / per-IP attempt limit (user-code RNG is fixed here), and showing the client and scope on the device page before approval
P1: anonymous dynamic client registration accepts any grant types/scopes/redirect URIs (
javascript://passes) and has no body size limitP1:
/introspectand/revokeclient authenticationThe implicit grant also issues a refresh token (
AccessTokenalways does)Generated by Claude Code