id-proto-debugger

The six OIDC flows

Read this before touching oauth2_oidc_1.html / oauth2_oidc_2.html or tests/oidc_flows.js. Five of the six flows sent the wrong response_type for months and every downstream check still looked plausible.

The grant dropdown on oauth2_oidc_1.html offers all six OIDC response types — code, id_token token, id_token, code id_token, code token, code id_token token — and until 2026-08-05 only the first of them worked. tests/oidc_flows.js now covers all six, against the mock STS and Keycloak alike. Three defects it found, all in the client:

What was NOT wrong, and is worth knowing before hunting further: the mock STS implements all seven response types correctly (fragment encoding, at_hash, c_hash, nonce echo), and the sub it issues is urn:sts-mock:user:<name> — namespaced on purpose, since sub is opaque and Keycloak’s is a UUID, so a test asserting sub === "<username>" would be a weaker test that only passes against an OP leaking the login name. The suite checks preferred_username instead, and that every artifact of one flow describes the same subject.

The same matrix runs against Keycloak, which asks the other half of the question: whether any of it interoperates with a real OP. tests/oidc_flows.js takes everything OP-specific from the environment, so the twenty-four jobs are one script. Three things had to be provisioned for it, and each is a way the run fails silently or confusingly if it is missing:

The one thing that actually broke on the first Keycloak run was in the test, and it is worth remembering: a JWKS is a set, and its order means nothing. makeVerifier() took jwks.keys[0], which worked for two months against the mock — it publishes exactly one key — and failed all twelve Keycloak jobs at the first token. Keycloak publishes its RSA-OAEP encryption key first and its RS256 signing key second, so the assertion complained that the token “should name the advertised key” about a key that signs nothing. The key is now chosen by kid, with three checks that a positional lookup cannot make: the kid must be one the OP publishes (verified by serving a JWKS with the signing key removed — it fails naming both sides, rather than falling back to a key that happens to work), the key’s use must not be enc, and its advertised alg must match the header’s. JWS_ALGS maps the algorithm onto node’s verifier explicitly, because ECDSA JWS signatures are raw R   S (node defaults to DER) and PS* needs a digest-length salt — get either wrong and a good signature reports as invalid.

Two differences the test has to be told about rather than assume: Keycloak’s sub is a UUID, a different string from the name typed at the login screen, so configureKeycloak() now exports ${FLOW}_USERNAME beside the existing ${FLOW}_USER (the UUID) and the jobs pass both — sub is opaque, and a test that expected the login name there would only pass against an OP that leaks it. And the browser-direct Token Request with a DPoP header is a non-simple cross-origin request, so Keycloak must both allow the debugger’s origin (webOrigins) and name DPoP in the preflight’s Access-Control-Allow-Headers. Measured 2026-08-05: it does — all six DPoP-on jobs exchange their code from the browser and come back with cnf.jkt equal to the page’s key. The status: 0 annotation stays for the day a deployment gets the webOrigins wrong, which is the failure that names nothing.

The twelve jobs exchange the hybrid flows’ code from the browser rather than through the api proxy. That is deliberate twice over: it keeps them gated on the STS alone (no api service, and no api CORS allow-list that has to name whichever origin the debugger is served from — a mismatch there fails as status: 0 with no message), and it is the leg that can carry a DPoP proof, which the proxied call cannot.

This workflow hands access tokens to other workflows

Two hand-offs run through oauth2_oidc_1.js and oauth2_oidc_2.js, and they behave differently on purpose. Neither does anything at all unless it has been started by the workflow that owns it, so an ordinary visit to either page is untouched by both.

offerTokenToHandoff() is called from all three of this page’s token-bearing responses: the token endpoint’s success handler, the Refresh Token grant, and renderAuthorizationEndpointResults() — which is where an Implicit or Hybrid flow’s access token arrives and the only place it does. A hand-off wired only to the token endpoint would leave four of the six flows above with a banner that never resolved. See docs/scim.md.