Get unstuck

Hint 1: Exact means exact

includes(redirectUri) on the registered list is the whole rule for web clients. Prefixes, origins and parsed paths all accept URIs that the client never registered, including ones that pass through an open redirector on the client’s own site.

Hint 2: A missing parameter is not permission

A grant without a challenge, a missing method and a missing verifier are reasons to refuse, not to skip the check. Check the verifier’s length and characters before hashing it.

Reveal the reference patch and explanation
Fixedauthorization-server.mjsJavaScript
const LOOPBACK = /^http:\/\/(127\.0\.0\.1|\[::1\]):\d{1,5}(\/.*)?$/;
const VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;

// Authorization endpoint: exact string match, plus any port on a loopback IP redirect URI.
export function redirectUriAllowed(client, redirectUri) {
  if (typeof redirectUri !== 'string') return false;
  if (client.redirectUris.includes(redirectUri)) return true;
  const loopback = LOOPBACK.exec(redirectUri);
  return Boolean(loopback) && client.redirectUris.includes(`http://${loopback[1]}${loopback[2] ?? ''}`);
}

// Token endpoint: PKCE is required, and only S256 is accepted.
export async function verifyPkce(grant, codeVerifier, sha256) {
  if (typeof grant.codeChallenge !== 'string' || grant.codeChallengeMethod !== 'S256') return false;
  if (typeof codeVerifier !== 'string' || !VERIFIER.test(codeVerifier)) return false;
  const bytes = await sha256(codeVerifier);
  const encoded = btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
  return encoded === grant.codeChallenge;
}

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.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download cases.mjs
How the browser sandbox runs your code

Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.

The same as in current Node.js:

  • ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag

Added to the sandbox so that it behaves like Node.js:

  • structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
  • URL and URLSearchParams, parsed by your browser’s own URL implementation
  • TextEncoder and TextDecoder for UTF-8
  • Intl.Collator and a locale-aware String.prototype.localeCompare
  • console.log and related methods, whose output appears with your results

Not available in the browser sandbox:

  • Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
  • Node.js modules and globals: import, require, process and Buffer
  • Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
  • Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
  • using declarations, Symbol.dispose and DisposableStack (explicit resource management)
  • Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace

Use the lesson in your own system

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).

Your 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?

RFC 9700: Best Current Practice for OAuth 2.0 Security, section 2.1

Related guide: Defend OAuth and OIDC against redirect and token attacks