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:
resetUI() set the response type with $("response_type"), not $("#response_type") — in all six OIDC branches, along with $("scope"). Without the #, jQuery looks for an ELEMENT NAMED response_type, matches nothing, and .val() is a silent no-op; the two OAuth2 branches beside them had the # and worked. So every OIDC flow sent whatever was already in the hidden input, which is the markup’s default of code — the five non-code flows all ran the Authorization Code flow, and the one flow that appeared to work did so because its correct value and the default happened to be the same string. This is why a test that only looks for “a token came back” is worthless here, and why the first assertion in that suite is on the request the page is about to send, read from the preview textarea triggerAuthZEndpointCall() actually navigates to (reading the hidden input instead would pass on a page that displays one thing and sends another).if(!!id_token) where if(!id_token) was meant, in the code id_token token branch of recreateUniqueGrantFlowElements() in oauth2_oidc_2.js: inverted, it replaced every id_token that DID arrive with the NO_ID_TOKEN_PRESENTED_IN_EXPECTED_LOCATIONS placeholder, so the one flow that returns all three artifacts was the one flow that never displayed its ID token.implicit_grant_access_token — the access token’s and the ID token’s. getElementById returned the access token for either, and the markup was invalid; fillGeneratedFields() kept working only because it selects on data-token-field rather than on the id. The ID token’s is now implicit_grant_id_token.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:
--features=dpop. DPoP is a preview feature in Keycloak 26: without it the server ignores the header, issues an ordinary Bearer token, and advertises no dpop_signing_alg_values_supported — so a DPoP job would fail at its last assertion with “the token came back unbound”, which reads as a client bug. All three compose files that start Keycloak now pass it, and the test checks the advertisement (RFC 9449 section 5.1) first and names the flag in the failure.standardFlowEnabled AND implicitFlowEnabled — oidc-all-flows-public, provisioned by configureKeycloak(). Keycloak gates the response types on that pair: code needs the first, id_token/id_token token the second, and all three Hybrids need both. A client with only the standard flow answers unsupported_response_type for four of the six, arriving as an error on the redirect rather than as anything the page did wrong.dpop.bound.access.tokens deliberately unset. In Keycloak that attribute means always require DPoP for this client, which would fail the six DPoP-off jobs. Left off, DPoP is optional exactly as the debugger’s own switch is, and both halves of the axis run against one client.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.
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.
?sdjwtvc=1, sd_jwt_vc.js) arrives with an
authorization endpoint and a client id that its own step 1 has just written,
starts the authorization request by itself, and navigates back when
the tokens land — this page is a waypoint in a numbered sequence.?tokenhandoff=1, token_handoff.js) is the
SCIM page asking for a bearer token, because RFC 7644 section 2 names one as
a SCIM authentication scheme and says nothing whatever about where one comes
from. It pre-fills nothing (that page knows a SCIM service root and
nothing about an authorization server, so every guess would produce a
request that fails for a reason nobody chose), it starts nothing, and it
does not navigate away — a reader who came here for a token is on the one
page that shows them what came back, and it offers a link rather than taking
the page away from them.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.