Match redirect URIs and verify PKCE

An authorization server accepts any redirect_uri that starts with a registered one, skips PKCE for codes issued without a challenge and accepts the plain method. Repair both checks so stolen or injected codes cannot be redeemed.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit authorization-server.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce both bypasses
Run the starter. A redirect_uri that starts with the registered callback and then walks to /redirect?to=… is accepted, and so are extra query parameters. At the token endpoint, a code issued without a challenge is redeemed with any verifier, and the plain method compares the verifier with a challenge that travelled in the browser.

2. Match the redirect URI exactly
Return true when client.redirectUris includes redirectUri. Then add the loopback exception only: http://127.0.0.1 or http://[::1], a port, and the rest of a registered loopback URI unchanged. No prefix, no origin comparison, no parsing, and no port exception for any other host.

3. Require PKCE with S256
Refuse a grant without a challenge (this also stops the downgrade in RFC 9700 §4.8, where an attacker strips code_challenge from their own request), a method other than S256, a missing verifier and one that breaks the 43–128 unreserved-character rule.

4. Compute the challenge the RFC way
Hash the verifier with the supplied sha256, base64-encode the bytes with btoa(String.fromCharCode(...bytes)), swap + and / for - and _, strip the = padding, and compare with the stored challenge. The RFC 7636 Appendix B example must pass.

SUPPLIED CONTRACT
redirectUriAllowed(client, redirectUri) runs at the authorization endpoint and returns true only when the code may be sent to redirectUri. client.redirectUris lists the registered URIs: https://app.example.test/callback, https://app.example.test/oauth/silent, and the native app’s http://127.0.0.1/callback and http://[::1]/callback. Compare strings exactly (RFC 9700 §2.1 and §4.1.3: simple string comparison, no parsing or normalizing). The one exception (RFC 8252 §7.3) is a registered loopback IP redirect URI: the request may add any port to it, as in http://127.0.0.1:51004/callback; every other character must match. verifyPkce(grant, codeVerifier, sha256) runs at the token endpoint and returns true only when the code_verifier proves the client that started the flow is redeeming the code. grant is what the server stored with the code: {codeChallenge, codeChallengeMethod}, either of which may be missing. This server requires PKCE with S256 for every client: refuse a grant without a challenge, a method other than "S256" (plain, and a missing method, which RFC 7636 §4.3 defaults to plain), a missing verifier, and a verifier that is not 43 to 128 characters from A–Z, a–z, 0–9, "-", ".", "_" and "~" (RFC 7636 §4.1). Otherwise compare BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) with the stored challenge (RFC 7636 §4.6). The sandbox has no Web Crypto, so await sha256(text) returns the 32 digest bytes as an array of numbers; base64url means the base64 alphabet with "-" and "_" for "+" and "/", and no "=" padding (RFC 7636 Appendix A). btoa is available.

REFERENCE
reference.mjs contains an explained solution: https://app.example.test/callback, https://app.example.test/oauth/silent, http://127.0.0.1/callback, http://127.0.0.1:51004/callback and http://[::1]:61023/callback are allowed. Refused: https://evil.example/callback, …/callback/../redirect?to=https://evil.example/ (an open redirector on the client), …/callback?next=…, …/callbacks, https://app.example.test.evil.example/callback, https://staging.app.example.test/callback (a subdomain), https://app.example.test@evil.example/callback, https://evil.example/cb?next=http://127.0.0.1:51004/callback, a loopback URI followed by a line break, a one-item list, http://app.example.test/callback, https://app.example.test:8443/callback (a port exception for a host that is not loopback), https://app.example.test:443/callback (a different spelling), http://127.0.0.1:51004/other, http://localhost:51004/callback and a redirect_uri that is not a string. The RFC 7636 Appendix B verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk matches challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, and a 128-character verifier with "." and "~" matches its own challenge. A wrong or missing verifier, the plain method, a missing, empty or padded method, an empty or padded stored challenge, a grant without a challenge (with or without a verifier), a verifier of 3 or 129 characters, one containing "+" and "/", and the challenge sent back as the verifier are refused.

SCOPE
The lab covers two checks of a real authorization server. It leaves out client authentication, binding the code to the client ID and redirect URI and using it once (RFC 6749 §4.1.2 and §4.1.3), state or nonce handling on the client, and refusing to redirect at all when the redirect URI is invalid (RFC 6749 §4.1.2.1). Registering exact URIs is also the client’s job: a registered callback that redirects onward is an open redirector (RFC 9700 §4.11). Native apps should register the loopback IP literal rather than localhost (RFC 8252 §8.3).

NEXT TASK
Which redirect URIs are registered for your OAuth clients today, and does your authorization server or identity provider compare them exactly? Do any of your clients still redeem codes without PKCE?

SOURCE
https://www.rfc-editor.org/rfc/rfc9700.html#section-2.1
