Skip to content

PPT-2536: authorize-endpoint validation specs (AU-02..AU-06, AU-13) - #18

Merged
camreeves merged 2 commits into
masterfrom
PPT-2536-p1-authorize
Jul 31, 2026
Merged

PPT-2536: authorize-endpoint validation specs (AU-02..AU-06, AU-13)#18
camreeves merged 2 commits into
masterfrom
PPT-2536-p1-authorize

Conversation

@camreeves

Copy link
Copy Markdown
Contributor

P1 coverage for the OAuth authorize endpoint. Test-only. 12 examples.

Row Pins
AU-02 implicit/hybrid/none/case-variant response types → 400 unsupported_response_type with no Location, on a registered client, plus a code positive control
AU-03 unregistered, //evil, javascript:, data:, userinfo-trick redirects → 401 with no Location; empty redirect_uri → the distinct 400 branch
AU-04 scheme/host/port/case/trailing-dot and extra-param/fragment/dot-segment/encoding variants — each table with an exact-match control
AU-05 custom-scheme (com.placeos.booking.panel://), localhost:8080 and LAN-IP redirects mint codes and echo that exact URI, plus a full native exchange with no secret
AU-06 state echoed byte-identical (a b&c=d?e#f/g+h%21) on grant and deny redirects; absent when not sent

Existing coverage was found and not duplicatedoauth_spec.cr:294 already covered response_type=token, but with an unregistered client_id, so the 400 wasn't attributable to the response type. These use a registered client with a control.

Correction made during review

AU-13 originally asserted 401 unauthorized_client; the endpoint returns 400 invalid_redirect_uri. authly's validate! runs valid_redirect? before client_authorized?, so client B presenting A's URI trips the redirect check and never reaches the client check. The security property is unchanged and still asserted — B refused, no access_token, with A redeeming the same code afterwards as a positive control.

Also fixed here

hostile_input_spec.cr:94 was vacuous: it asserted Location didn't contain evil.example on an unauthenticated authorize request, which 303s to /auth/login — so the expression was false.should_not be_true and never exercised redirect validation at all. Now asserts the positive (303 + exact Location).

Defect pinned as pending

AU-11 — authorization codes are replayable. No code store exists; the code is a self-contained JWT and nothing marks it spent, so one code mints unlimited token pairs for its full 10-minute TTL. RFC 6749 §4.1.2 and a Doorkeeper parity break. Not fixed here: it interacts with RF-05 (the SPA boot race double-submits), so single-use has to be spend-once-return-same-token or a grace window, not revoke-the-family.

186 examples, 0 failures, 3 pending.

🤖 Generated with Claude Code

camreeves and others added 2 commits July 31, 2026 14:04
PPT-2536 test-matrix rows for the authorization endpoint. 12 new examples
in spec/controllers/authorize_validation_spec.cr:

  AU-02  implicit/hybrid/`none`/`CODE` response types rejected with
         `unsupported_response_type` and no Location, on a *registered*
         client (oauth_spec.cr only covered the unregistered `client_id=x`
         case, where the 400 is not attributable to the response type)
  AU-03  unregistered / protocol-relative / javascript: / data: / userinfo
         redirect URIs produce a non-redirectable 401, never a Location;
         empty redirect_uri is the distinct 400 invalid_redirect_uri branch
  AU-04  exact-match redirect validation: scheme, host case, subdomain,
         port, trailing dot, extra query/path/fragment, percent-encoded and
         whitespace near-misses all rejected
  AU-05  the three redirect shapes real dev clients register — custom
         scheme, http://localhost:*, LAN IP — accepted from a multi-URI
         registration, plus the full native code exchange with no secret
  AU-06  `state` echoed byte-identical on both the grant and the deny
         redirect using a reserved-character value, and omitted entirely
         when the client sends none

Every rejection table carries a positive control in the same example, so a
fixture that stopped minting codes at all cannot make the table pass
vacuously; every redirect asserts its TARGET, not just the status code.

Two known divergences are recorded as `pending` rather than silently
skipped, because both need a decision, not just a test:

  AU-11  authorization codes are stateless JWTs with no consumption store,
         so they are replayable for their full 10-minute TTL. Ruby
         Doorkeeper revoked the grant on first use (RFC 6749 4.1.2).
         Fixing it interacts with the RF-05 double-submit finding.
  AU-13  the code carries no `client_id` claim; cross-client redemption is
         blocked only via `redirect_uri`. Two clients with overlapping
         registered URIs defeat it (RFC 6749 4.1.3). The passing AU-13
         cases pin both directions of the redirect-mediated binding.

Also fixes a vacuous assertion in hostile_input_spec.cr: the open-redirect
case ran unauthenticated, so `Location?.try(&.includes?("evil.example"))
.should_not be_true` was asserting against the /auth/login bounce and would
have passed even if the signed-in path leaked a code. Now asserts the
positive (303 to exactly /auth/login).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cross-client case asserted 401 unauthorized_client; the endpoint returns
400 invalid_redirect_uri. authly's validate! runs valid_redirect? BEFORE
client_authorized?, so client B presenting client A's redirect_uri trips the
redirect check and never reaches the client check.

The security property is unchanged and still asserted — B is refused and no
access_token is issued, with client A redeeming the same code afterwards as a
positive control so the refusal cannot pass for the wrong reason. Only which
guard fires differs, and pinning it means a reordering upstream surfaces as a
deliberate decision rather than a silent change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@camreeves
camreeves merged commit 07518d3 into master Jul 31, 2026
7 checks passed
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.

1 participant