RP-initiated logout
GET/POST /tenants/{tenant}/protocol/openid-connect/logout implements OpenID
Connect RP-Initiated Logout
1.0.
Ending a session revokes it and every grant whose session_id names it. The
access tokens those grants minted are self-contained at+jwt JWTs, so a
resource server that only checks a signature locally keeps accepting one until
its own exp regardless — but
/introspect reports it inactive
immediately, and /userinfo refuses it outright with invalid_token, which is
what closes that gap before exp for both doors. The paragraph on the back-channel
sender under "Front-channel and back-channel logout" has the same account.
A client registers its post_logout_redirect_uri values ahead of time.
seed client --post-logout-redirect-uri registers them as it creates a
client, and refuses a client that already exists — deliberately, because a
re-run that quietly widened a registered redirect list is how an allowlist
grows by accident. demo-spa was seeded back in
Bootstrap, so this walkthrough sets the column directly;
PATCH /admin/tenants/{tenant}/clients/{id} amends the list with
If-Match, and RFC 7592's own client-configuration endpoint — a client
amending itself with its registration token — is P13's:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET post_logout_redirect_uris = ARRAY['http://localhost:8080/logged-out']
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'demo-spa';
"
Signing in exactly as Path A does,
then redeeming a reused-session code for an id_token, gives an
id_token_hint naming this session:
CODE=$(curl -sS -b cookies.txt -D - -o /dev/null --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid' \
--data-urlencode 'state=xyz-hint' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/auth" \
| sed -n 's/.*[Ll]ocation: .*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')
TOKEN_RESPONSE=$(curl -sS \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" \
"http://localhost:3000/tenants/demo/protocol/openid-connect/token")
ID_TOKEN=$(python3 -c "import json,sys; print(json.load(sys.stdin)['id_token'])" <<< "$TOKEN_RESPONSE")
REFRESH_TOKEN=$(python3 -c "import json,sys; print(json.load(sys.stdin)['refresh_token'])" <<< "$TOKEN_RESPONSE")
curl -sS -b cookies.txt -D - -o /dev/null \
--get \
--data-urlencode "id_token_hint=$ID_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
--data-urlencode 'state=xyz-bye' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 302 Found
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
cache-control: no-store
location: http://localhost:8080/logged-out?state=xyz-bye
content-length: 0
Both cookies are cleared, not just the one this login set: logout ends the whole SSO session, and a browser could hold a live persistent cookie from a different, remembered login even though this walkthrough's own login did not set one.
The hint names the session the cookie itself belongs to (OIDC Core §3.1.2.2
validates it — this tenant's own keys, this tenant's issuer, an access token
refused by typ), so §2's confirmation is skipped and the exact-match
post_logout_redirect_uri is honoured. A sid naming a session outside
this browser's own resolved set — stale, or another browser's — is treated
as a hint that names nothing usable: confirmation falls back to whichever
of this browser's own sessions was most recently active, and the
confirmation page names that session, not the one the sid asked for, so
nobody is misled about which session confirming will end. A hint past its
exp is read the same way (below).
The refresh
token this session's grant issued is now refused:
curl -sS \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/token"
{ "error": "invalid_grant", "error_description": "The code or token is not valid." }
An ID token past its exp, sent as the hint
A hint is usually older than five minutes by the time a client sends it —
the console's own sign-out sends the ID token it stored at sign-in — so
RP-Initiated Logout §2 and OIDC Core §3.1.2.2 both ask the OP to accept one
"even when the exp time has passed". At the logout endpoint what bounds
that is the session the hint's sid names, not the clock: the sid has to
equal a session still live in this browser's own cookies. At /authorize
the hint only constrains the request; it authenticates nobody.
This section ran against a fresh stack of its own (ODUDU_REAP_ENABLED=false)
published on port 3172, so every URL below is the port it ran on, seeded
only with demo, demo-spa and ada as Bootstrap does. The
redirect list from the section above is in place, and demo-spa's ID token
lifetime is lowered to its minimum of five seconds, so a hint can be shown past
its exp in the time it takes to run the next block. psql is a function for
the compose stack's database, the way odudu is for its CLI:
psql() { docker compose -f infra/docker/compose.yaml exec -T postgres psql -U odudu -d odudu "$@"; }
BASE=http://localhost:3172/tenants/demo/protocol/openid-connect
LOGIN=http://localhost:3172/tenants/demo/login-actions/authenticate
psql -c "UPDATE client_oidc_config SET id_token_ttl_seconds = 5
FROM clients WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'demo-spa';"
UPDATE 1
Two sign-ins, each from its own browser (its own cookie jar), each
redeemed for an ID token. signin is the shell variables
section's sequence
with a cookie jar added; claims prints what a token says about itself and
sid_of reads its sid. Seven seconds later both tokens are past their
exp:
signin() { # $1 = cookie jar; sets ID_TOKEN
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
AUTH_SESSION_ID=$(curl -sS --get --data-urlencode 'response_type=code' --data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' --data-urlencode 'scope=openid' \
--data-urlencode 'state=s' --data-urlencode "code_challenge=$CHALLENGE" --data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" | sed -n '/name="auth_session_id"/{s/.*value="\([^"]*\)".*/\1/p;q;}')
CODE=$(curl -sS -c "$1" -D - -o /dev/null --data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' --data-urlencode 'password=correct-horse-battery' "$LOGIN" \
| sed -n 's/.*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')
ID_TOKEN=$(curl -sS --data-urlencode 'grant_type=authorization_code' --data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' --data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" "$BASE/token" | sed -n 's/.*"id_token":"\([^"]*\)".*/\1/p')
}
sid_of() { claims "$1" | python3 -c "import json,sys; print(json.load(sys.stdin)['sid'])"; }
claims() { python3 - "$1" <<'PY'
import sys,json,base64
p=sys.argv[1].split('.')[1]; p+='='*(-len(p)%4)
c=json.loads(base64.urlsafe_b64decode(p))
print(json.dumps({k:c[k] for k in ('iat','exp','sid') if k in c}))
PY
}
signin jarA.txt; TOKEN_A=$ID_TOKEN; claims "$TOKEN_A"
signin jarB.txt; TOKEN_B=$ID_TOKEN; claims "$TOKEN_B"
sleep 7; date -u +%s
SID_A=$(sid_of "$TOKEN_A"); SID_B=$(sid_of "$TOKEN_B")
echo "SID_A=$SID_A"; echo "SID_B=$SID_B"
{"iat": 1792007015, "exp": 1792007020, "sid": "01a13bf1-0b5a-735c-befe-804b753cb023"}
{"iat": 1792007015, "exp": 1792007020, "sid": "01a13bf1-0c1c-7870-b1df-00462a5f5400"}
1792007022
SID_A=01a13bf1-0b5a-735c-befe-804b753cb023
SID_B=01a13bf1-0c1c-7870-b1df-00462a5f5400
The precondition that makes the next calls safe is the session, not the
clock, and it is shown rather than asserted: both sessions are live, and
post_logout_redirect_uris is what demo-spa may be sent to:
LIVE="select id, expires_at > now() as live from sessions where id in ('$SID_A','$SID_B') order by created_at;"
psql -c "$LIVE"
psql -c "select c.client_id, o.post_logout_redirect_uris, o.id_token_ttl_seconds
from client_oidc_config o join clients c on c.id = o.client_id where c.client_id = 'demo-spa';"
id | live
--------------------------------------+------
01a13bf1-0b5a-735c-befe-804b753cb023 | t
01a13bf1-0c1c-7870-b1df-00462a5f5400 | t
(2 rows)
client_id | post_logout_redirect_uris | id_token_ttl_seconds
-----------+------------------------------------+----------------------
demo-spa | {http://localhost:8080/logged-out} | 5
(1 row)
First the cases that still ask. Browser B presents the hint of session A:
that sid names a session B's cookie does not hold, so the token does not
belong to the current session and §2's confirmation fires. A hint with the
last character of its signature changed ($FORGED) is no hint at all,
however its exp reads. And a request with no session cookie has no
session to end, so it gets the no-session page. The pipe prints each
answer's status line, any location or set-cookie, and the page's
<title>:
FORGED="${TOKEN_A%?}$([ "${TOKEN_A: -1}" = A ] && echo B || echo A)"
curl -sS -b jarB.txt -D - --get --data-urlencode "id_token_hint=$TOKEN_A" \
--data-urlencode 'client_id=demo-spa' --data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
--data-urlencode 'state=xyz-bye' "$BASE/logout" | grep -iE '^HTTP|^location|^set-cookie|<title>'
echo ---
curl -sS -b jarA.txt -D - --get --data-urlencode "id_token_hint=$FORGED" \
--data-urlencode 'client_id=demo-spa' --data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
--data-urlencode 'state=xyz-bye' "$BASE/logout" | grep -iE '^HTTP|^location|^set-cookie|<title>'
echo ---
curl -sS -D - --get --data-urlencode "id_token_hint=$TOKEN_A" --data-urlencode 'client_id=demo-spa' "$BASE/logout" \
| grep -iE '^HTTP|^location|^set-cookie|<title>'
HTTP/1.1 200 OK
<title>Sign out?</title>
---
HTTP/1.1 200 OK
<title>Sign out?</title>
---
HTTP/1.1 200 OK
<title>Already signed out</title>
The first two are the confirmation form and the third the no-session page;
none carries a location or a set-cookie. Neither session ended:
psql -c "$LIVE"
id | live
--------------------------------------+------
01a13bf1-0b5a-735c-befe-804b753cb023 | t
01a13bf1-0c1c-7870-b1df-00462a5f5400 | t
(2 rows)
/authorize reads the same lapsed hint as a constraint. Browser B's
session is live and it is the hint's subject, so a code is issued; with
prompt=none and no session cookie there is nobody to answer for, so
login_required comes back as for a request with no hint; and a lapsed
hint with its signature changed is invalid_request, as an unexpired one
would be (the code is shortened to …):
AUTH=(--get --data-urlencode 'response_type=code' --data-urlencode 'client_id=demo-spa'
--data-urlencode 'redirect_uri=http://localhost:8080/callback' --data-urlencode 'scope=openid'
--data-urlencode 'state=xyz-auth' --data-urlencode "code_challenge=$CHALLENGE"
--data-urlencode 'code_challenge_method=S256')
echo "# lapsed hint, live session of its subject"
curl -sS -b jarB.txt -D - -o /dev/null "${AUTH[@]}" --data-urlencode "id_token_hint=$TOKEN_B" "$BASE/auth" \
| grep -iE '^HTTP|^location' | sed 's/code=[^&]*/code=…/'
echo "# lapsed hint, prompt=none, no session cookie"
curl -sS -D - -o /dev/null "${AUTH[@]}" --data-urlencode 'prompt=none' --data-urlencode "id_token_hint=$TOKEN_B" "$BASE/auth" \
| grep -iE '^HTTP|^location'
echo "# forged lapsed hint, live session"
curl -sS -b jarB.txt -D - -o /dev/null "${AUTH[@]}" \
--data-urlencode "id_token_hint=${TOKEN_B%?}$([ "${TOKEN_B: -1}" = A ] && echo B || echo A)" "$BASE/auth" \
| grep -iE '^HTTP|^location'
# lapsed hint, live session of its subject
HTTP/1.1 302 Found
location: http://localhost:8080/callback?code=…&state=xyz-auth&iss=http%3A%2F%2Flocalhost%3A3172%2Ftenants%2Fdemo
# lapsed hint, prompt=none, no session cookie
HTTP/1.1 302 Found
location: http://localhost:8080/callback?error=login_required&error_description=The+user+is+not+signed+in.&state=xyz-auth&iss=http%3A%2F%2Flocalhost%3A3172%2Ftenants%2Fdemo
# forged lapsed hint, live session
HTTP/1.1 302 Found
location: http://localhost:8080/callback?error=invalid_request&error_description=id_token_hint+is+not+valid&state=xyz-auth&iss=http%3A%2F%2Flocalhost%3A3172%2Ftenants%2Fdemo
Now browser A presents the hint of its own session at the logout
endpoint, more than five seconds past its exp. The sid equals a live
session in this browser, so the OP asks nothing and redirects:
curl -sS -b jarA.txt -D - -o /dev/null --get --data-urlencode "id_token_hint=$TOKEN_A" \
--data-urlencode 'client_id=demo-spa' --data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
--data-urlencode 'state=xyz-bye' "$BASE/logout"
HTTP/1.1 302 Found
x-request-id: 01a13bf1-29e7-7950-b253-0b1614d3b01f
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
cache-control: no-store
location: http://localhost:8080/logged-out?state=xyz-bye
content-length: 0
Date: Wed, 14 Oct 2026 19:43:43 GMT
Connection: keep-alive
Keep-Alive: timeout=72
Session A is ended and B untouched, and the audit trail records the end
exactly as it does for an unexpired hint — session.ended with
via: logout; the audit vocabulary has no member for the hint's outcome:
psql -c "$LIVE"
psql -c "select action, outcome, detail from audit_events where resource_id = '$SID_A' order by occurred_at;"
id | live
--------------------------------------+------
01a13bf1-0b5a-735c-befe-804b753cb023 | f
01a13bf1-0c1c-7870-b1df-00462a5f5400 | t
(2 rows)
action | outcome | detail
-----------------+---------+-------------------
session.created | allowed | {}
session.ended | allowed | {"via": "logout"}
(2 rows)
That is the bound at the logout endpoint: a lapsed hint is good for as long
as the session it names is live and in the presenting browser's cookies,
and for nothing else. What it widens is the exposure of a leaked ID token:
it used to be usable for a logout for the token's lifetime and now is for
the session's, and what it can do in that time is end the one session it
names. OIDC-RPINITIATED-2-05 holds the rest of the matrix to the same
answer. At /authorize the equivalent is OIDC-CORE-3.1.2.2-08: a lapsed
hint with a live session of another subject is still login_required
under prompt=none and still gets the login form otherwise. Token
exchange does not read a lapsed subject_token.
Token introspection and revocation
Local validation cannot see that a session just ended — the access token
above is still a validly signed JWT until its own exp — so the two walks
below use a freshly seeded tenant and confidential client of their own,
revokedoc/revoke-doc-client, so a resource server that wants to see a
revocation before exp has something to call. seed client has no
--audience flag (see the roadmap's entry on odudu seed), so the
client's own client_oidc_config.audiences is set directly, the same way
the section above sets post_logout_redirect_uris
directly. PATCH /admin/tenants/{tenant}/clients/{id} sets it too, with
If-Match — audiences is one of the six list fields that require one
(Admin paths) — and a walkthrough driven
from a shell with no admin token in it reaches for SQL instead:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET audiences = ARRAY['revoke-doc-client']
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'revoke-doc-client';
"
revoke-doc-client now names itself in its own audiences, which is what lets
it call /introspect for its own tokens — callerIsAddressed entitles a caller
through its own client_id or any of its registered audiences, and this
client's request for no particular resource resolves to exactly that set
(resolveAudience).
Signing in as revoke-doc-client — the same PKCE flow as
Path A, against this client instead
of demo-spa — and introspecting the access token it receives, while the
session is still live:
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/token/introspect" \
--data-urlencode "token=$ACCESS_TOKEN"
{
"active": true,
"scope": "openid",
"client_id": "revoke-doc-client",
"sub": "01a0cb1f-5551-…",
"aud": ["revoke-doc-client", "http://localhost:3000/tenants/revokedoc"],
"token_type": "Bearer",
"exp": 1790114523,
"iat": 1790114223
}
Ending that session — the same id_token_hint logout as above — and
introspecting the identical, still-unexpired access token again:
curl -sS -b cookies.txt --get \
--data-urlencode "id_token_hint=$ID_TOKEN" \
--data-urlencode "client_id=revoke-doc-client" \
--data-urlencode "state=bye-1" \
"http://localhost:3000/tenants/revokedoc/protocol/openid-connect/logout"
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/token/introspect" \
--data-urlencode "token=$ACCESS_TOKEN"
{ "active": false }
exp above is 1790114523; this second call landed at 1790114223 — 300
seconds still on the clock, and every one of them made no difference, because
logout revoked the grant this token names along with the session
(tokenGrantRepository(tx).revokeForSession, called by endSession), and
introspection checks that revoked_at before it ever looks at the session (RFC
7662 §8.2) — not merely the token's own signature. A resource server that only
verified the JWT locally would still be accepting this token.
/revoke (RFC 7009) is the other half — ending a grant deliberately, from
either side of it, rather than waiting for a session to end one. A fresh
sign-in, the same way, gives a second, still-live access and refresh token
pair. Revoking the refresh token:
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/revoke" \
--data-urlencode "token=$REFRESH_TOKEN"
HTTP/1.1 200 OK
cache-control: no-store
content-length: 0
The grant is really gone, not merely marked for it: redeeming the same refresh token now fails the same way an unknown one would (RFC 6749 §5.2's own rule against distinguishing them),
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/token" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN"
{ "error": "invalid_grant", "error_description": "The code or token is not valid." }
and so does its sibling access token — the two name the same token_grants row,
so revoking either invalidates both (RFC 7009: "Both revocation directions go
through the grant, not the token"):
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/token/introspect" \
--data-urlencode "token=$ACCESS_TOKEN"
{ "active": false }
Revoking it again is RFC 7009 §2.2's other rule, pinned separately from the
first 200 above: an already-revoked token is not an error either.
curl -sS -u revoke-doc-client:revoke-doc-secret \
-X POST "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/revoke" \
--data-urlencode "token=$REFRESH_TOKEN"
HTTP/1.1 200 OK
cache-control: no-store
content-length: 0
And discovery now names the endpoint that did all of this:
"introspection_endpoint": "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/token/introspect",
"revocation_endpoint": "http://localhost:3000/tenants/revokedoc/protocol/openid-connect/revoke",
A second, separate sign-in with no id_token_hint gets the
confirmation page §2 requires instead of an immediate redirect — nothing is
ended by this GET alone:
curl -sS -b cookies2.txt \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign out?</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Sign out?</h1>
<p>Signing out ends this session for every application that uses it.</p>
<form method="post" action="/tenants/demo/protocol/openid-connect/logout">
<input type="hidden" name="session_id" value="01a0de36-80e1-…">
<input type="hidden" name="csrf" value="yU7AU__xF-kY…">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
(session_id and csrf shortened, as elsewhere in this document.)
session_id is the session's id — the id half of the cookie's entry,
and the sid every token for it carries — so it is not a secret and proves
nothing on its own. csrf is what does: base64url of an HMAC-SHA256 over
logout-confirm:<session id>, keyed by the secret half of the entry.
The POST recomputes it from the entry this browser's cookie presents for
that session and compares in constant time, and the session must also be
one the cookie still resolves to. A forger who knows the id from any token
still cannot produce the token, and the secret itself never appears in the
page. Two defences stand beside it: the cookie is SameSite=Lax, so a
cross-site POST carries none, and a session_id outside the cookie's own
set is refused.
The right session_id without the token, from the same browser, is
refused with exactly the page a non-member session_id gets, and ends
nothing:
SID2=$(curl -sS -b cookies2.txt \
"http://localhost:3094/tenants/demo/protocol/openid-connect/logout" \
| sed -n 's/.*name="session_id" value="\([^"]*\)".*/\1/p')
curl -sS -b cookies2.txt -D - -X POST \
--data-urlencode "session_id=$SID2" \
"http://localhost:3094/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 400 Bad Request
content-type: text/html
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'
x-frame-options: DENY
referrer-policy: no-referrer
cache-control: no-store
x-content-type-options: nosniff
content-language: en
content-length: 880
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Can't sign out</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Can't sign out</h1>
<p>This sign-out attempt is no longer valid. Go back and try again.</p>
</main>
</body>
</html>
(x-request-id, Date and the keep-alive headers are omitted. This page and
the refusal come from a fresh sign-in into cookies2.txt, on
http://localhost:3094, with demo seeded as Bootstrap does.
cache-control comes last of the page headers, where pageHeaders puts it.)
A post_logout_redirect_uri that is not an exact match to a registered
value — a trailing slash, a query string, a different host — is refused,
and the session still ends: §3's redirect rule is about the redirect
alone, never about whether logout happened.
What a session leaves in the audit log
A session writes one session row when a login creates it and one when it
ends, each in the transaction that did the work. A signed-in /authorize
that reuses a session creates nothing and writes nothing. This run signs in
with a fresh jar, fetches the confirmation page for the session_id and
csrf it carries, and posts both back. The sign-in and the confirming
POST each carry their own x-request-id — the page fetch between them
writes no row and carries none — so the query below reads those two requests
and nothing else:
AUTH_SESSION_ID=$(curl -sS --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid' \
--data-urlencode 'state=xyz-audit' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" | sed -n '/name="auth_session_id"/{s/.*value="\([^"]*\)".*/\1/p;q;}')
curl -sS -c cookies-audit.txt -o /dev/null -w '%{http_code}\n' \
-H 'x-request-id: logout-csrf-doc-sign-in' \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery' \
"$LOGIN"
PAGE=$(curl -sS -b cookies-audit.txt \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout")
SESSION_ID=$(printf '%s' "$PAGE" | sed -n 's/.*name="session_id" value="\([^"]*\)".*/\1/p')
CSRF=$(printf '%s' "$PAGE" | sed -n 's/.*name="csrf" value="\([^"]*\)".*/\1/p')
curl -sS -b cookies-audit.txt -o /dev/null -w '%{http_code}\n' \
-H 'x-request-id: logout-csrf-doc-confirm' \
--data-urlencode "session_id=$SESSION_ID" \
--data-urlencode "csrf=$CSRF" \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
docker compose exec -T postgres psql -U odudu -d odudu -x -P 'null=(null)' -c \
"select e.action, e.outcome, e.actor_subject_id, c.client_id as actor_client,
e.resource_type, e.resource_id, e.request_id, e.ip, e.detail
from audit_events e left join clients c on c.id = e.actor_client_id
where e.event_type = 'session'
and e.request_id in ('logout-csrf-doc-sign-in', 'logout-csrf-doc-confirm')
order by e.occurred_at;"
302
200
-[ RECORD 1 ]----+-------------------------------------
action | session.created
outcome | allowed
actor_subject_id | 01a0db22-1c92-7730-9d37-4085f28eca2c
actor_client | demo-spa
resource_type | session
resource_id | 01a0de36-817a-78e7-9515-96498c511556
request_id | logout-csrf-doc-sign-in
ip | 172.20.0.1
detail | {}
-[ RECORD 2 ]----+-------------------------------------
action | session.ended
outcome | allowed
actor_subject_id | 01a0db22-1c92-7730-9d37-4085f28eca2c
actor_client | (null)
resource_type | session
resource_id | 01a0de36-817a-78e7-9515-96498c511556
request_id | logout-csrf-doc-confirm
ip | 172.20.0.1
detail | {"via": "logout"}
On the same stack as what a refused login leaves
behind:
ada is subject 01a0db22-1c92-…, and resource_id is the session the
demo-session cookie's entry named — its id half, never its secret.
detail.via says how a session ended: logout here, admin when the admin
API's DELETE …/sessions/{sid} ends it (beside that route's own
admin_mutation row), and evicted when a login past the tenant's
max_sessions_per_browser ends the browser's least recently active session,
naming that session's own subject. Ending a session that has already ended
writes no second row.
The same request over POST
§2 requires both methods at this endpoint, so an RP may serialize the
request parameters into a form body instead of a query string. It is the
same request and gets the same answer. The GET above ended the session
cookies.txt held, so this needs a session of its own: run the sign-in and
the code-for-id_token exchange again into a second jar — the same two
blocks, with -c cookies-post.txt on the login and -b cookies-post.txt
on the authorize — and then:
curl -sS -b cookies-post.txt -D - -o /dev/null -X POST \
--data-urlencode "id_token_hint=$ID_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
--data-urlencode 'state=xyz-bye' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 302 Found
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
cache-control: no-store
location: http://localhost:8080/logged-out?state=xyz-bye
content-length: 0
(x-request-id, Date and the keep-alive headers are omitted, as
elsewhere in this document.) The session is gone, so the refresh token its
grant issued is refused exactly as after the GET:
curl -sS \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$REFRESH_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/token"
{ "error": "invalid_grant", "error_description": "The code or token is not valid." }
Two messages therefore arrive at the same POST: this one, and the
confirmation form above submitting back. The form's hidden session_id is
what tells them apart, so a body carrying it is a confirmation and a body
without it is a logout request. A confirmation is honoured only with the
csrf token the cookie's secret derives; a body without session_id is
read as a request — which, with no hint, is answered by the confirmation
page and ends nothing.
A client_id that disagrees with the hint
§2 requires the OP to verify a client_id sent alongside an
id_token_hint against the client the hint was issued to. For a request to
turn on that comparison and nothing else, the client named has to be one
whose registration would otherwise have allowed the redirect — so
demo-post, seeded in A confidential client
above, gets the same value demo-spa has — set directly for the reason
RP-initiated logout gives, since the client already
exists:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET post_logout_redirect_uris = ARRAY['http://localhost:8080/logged-out']
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'demo-post';
"
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
SELECT clients.client_id, client_oidc_config.post_logout_redirect_uris
FROM client_oidc_config JOIN clients ON clients.id = client_oidc_config.client_id
WHERE clients.client_id IN ('demo-post', 'demo-spa')
ORDER BY clients.client_id;
"
UPDATE 1
client_id | post_logout_redirect_uris
-----------+------------------------------------
demo-post | {http://localhost:8080/logged-out}
demo-spa | {http://localhost:8080/logged-out}
(2 rows)
That SELECT is not decoration. A disagreeing client_id drops the
requested redirect before the registered list is ever consulted, so a run
in which demo-post had no registered value produces byte-identical
output to the one below — §3 would have refused the redirect on its own and
the transcript would demonstrate nothing about §2's comparison. The two
rows above are what makes the answer attributable, which is also why the
WHERE clause is there: by this point the document has seeded eleven
clients, and the two that matter have to be shown rather than found in a
listing.
Then a third session of its own, signed in as before into
cookies-aud.txt, and a hint whose aud is demo-spa sent with a
client_id of demo-post:
curl -sS -b cookies-aud.txt -X POST \
--data-urlencode "id_token_hint=$ID_TOKEN" \
--data-urlencode 'client_id=demo-post' \
--data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign out?</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Sign out?</h1>
<p>Signing out ends this session for every application that uses it.</p>
<form method="post" action="/tenants/demo/protocol/openid-connect/logout">
<input type="hidden" name="session_id" value="01a0cb1f-d286-…">
<input type="hidden" name="client_id" value="demo-post">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
(session_id shortened.) Nothing was ended, and the form carries no
post_logout_redirect_uri at all: §4 says information that failed to
validate is not used, so the hint and the redirect it would have
authorised are dropped together. The identical request with
client_id=demo-spa — the client the hint names — ends the session and
redirects:
curl -sS -b cookies-aud.txt -D - -o /dev/null -X POST \
--data-urlencode "id_token_hint=$ID_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'post_logout_redirect_uri=http://localhost:8080/logged-out' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 302 Found
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
cache-control: no-store
location: http://localhost:8080/logged-out
content-length: 0
Front-channel and back-channel logout
OpenID Connect Front-Channel Logout 1.0
asks the OP to render, on the page it shows after ending a session, one
<iframe> per client that registered a frontchannel_logout_uri and held
a grant under that session. Back-Channel Logout 1.0
asks it to also POST a signed Logout Token to every client that
registered a backchannel_logout_uri and held a grant under that session.
seed client has no flag for either URI (see
the roadmap's entry on odudu seed),
so a second client is seeded and given both directly, the same way
post_logout_redirect_uris was set above.
PATCH /admin/tenants/{tenant}/clients/{id} sets both, revalidating them
the way registration does — a frontchannel_logout_uri that shares no
registered redirect URI's origin is refused 400 there
(Admin paths):
odudu seed client \
--tenant demo --client-id reports-widget --client-secret reports-widget-secret \
--redirect-uri http://localhost:9100/callback
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET frontchannel_logout_uri = 'http://localhost:9100/logout',
frontchannel_logout_session_required = true,
backchannel_logout_uri = 'https://127.0.0.1:9443/backchannel'
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'reports-widget';
"
(http://localhost:9100 is for reachability in this local walkthrough only.
isValidLogoutUri refuses http unconditionally for a URI registered through
dynamic registration or seed client — see the front-channel logout reading
note's
clause table — and this direct UPDATE is the one path in this document that
bypasses that check entirely, the same way it bypasses the
domain/port/scheme-matching check on frontchannel_logout_uri itself.)
Signing in as Path A does, then
reusing that same session's cookie for a second, consent-free authorization
against reports-widget and redeeming its code, gives the session a grant
under each client:
curl -sS -b cookies.txt --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=reports-widget' \
--data-urlencode 'redirect_uri=http://localhost:9100/callback' \
--data-urlencode 'scope=openid' \
--data-urlencode 'state=xyz-rw' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/auth"
curl -sS \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$RW_CODE" \
--data-urlencode 'redirect_uri=http://localhost:9100/callback' \
--data-urlencode 'client_id=reports-widget' \
--data-urlencode "code_verifier=$VERIFIER" \
-u reports-widget:reports-widget-secret \
"http://localhost:3000/tenants/demo/protocol/openid-connect/token"
Ending that session with no post_logout_redirect_uri at all — the branch
that renders the signed-out page rather than redirecting away from it —
now frames reports-widget:
curl -sS -b cookies.txt --get \
--data-urlencode "id_token_hint=$ID_TOKEN" \
"http://localhost:3094/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 200 OK
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
content-type: text/html
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'; frame-src http://localhost:9100
x-frame-options: DENY
referrer-policy: no-referrer
cache-control: no-store
x-content-type-options: nosniff
content-language: en
content-length: 318
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Signed out</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Signed out</h1>
<p>You have been signed out.</p>
<iframe src="http://localhost:9100/logout?iss=http%3A%2F%2Flocalhost%3A3094%2Ftenants%2Fdemo&sid=01a137d7-eae6-…"></iframe>
</main>
</body>
</html>
(sid shortened; it is the id half of the session cookie's entry, and iss is
this tenant's issuer. From the same stack at http://localhost:3094, with the
two commands above run against it and x-request-id, Date and the keep-alive
headers omitted.) sid is present because reports-widget registered
frontchannel_logout_session_required; a client that had not would be framed
with iss alone. demo-spa itself is not framed here — it registered no
frontchannel_logout_uri at all, and §2's own rule is that a client without one
is not framed and contributes no origin, which frame-src above bears out: it
names reports-widget's origin and nothing else.
This is an attempt, not a delivered notification. The iframe's response is
never read back, and whether reports-widget ever sees the request depends on
browser behaviour this OP does not control: A spike found that a cookie with no
explicit SameSite is never sent on this framed cross-site request at all, in
every browser tested, and a cookie that opts in with SameSite=None; Secure is
still subject to third-party-cookie blocking that Safari and Firefox apply by
default and Chrome allows a user or administrator to apply. ADR 0034 has the
full reasoning.
Back-channel logout is a server-to-server POST, so none of that applies to
it — but it went through the same logout above, because reports-widget
registered backchannel_logout_uri alongside frontchannel_logout_uri in the
UPDATE further up, and ending a session enqueues a delivery for every client
that registered either. This sub-section's own commands were re-run against a
fresh session on the same compose stack (per Bootstrap),
started with ODUDU_LOGOUT_SENDER_ENABLED=false on the environment so the
one-shot command below is what claims the row, not the server's own schedule.
Queried straight after that same GET .../logout call:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
SELECT c.client_id, d.endpoint, d.attempts, d.delivered_at, d.last_error
FROM backchannel_logout_deliveries d JOIN clients c ON c.id = d.client_id
WHERE c.client_id = 'reports-widget' AND d.endpoint LIKE '%127.0.0.1%';
"
client_id | endpoint | attempts | delivered_at | last_error
----------------+------------------------------------+----------+--------------+------------
reports-widget | https://127.0.0.1:9443/backchannel | 0 | |
(1 row)
One row, written in the same transaction that ended the session — before
any pass has looked at it. odudu send-logouts is what drains it, on its
own schedule or as the one-shot command below:
odudu send-logouts
{"ran":true,"delivered":0,"failed":1}
Ending a session tells the relying parties that were part of it. A
session that ends enqueues one back-channel Logout Token per client that
registered a backchannel_logout_uri and used the session, and a third
pass, send-logouts, delivers them — every ODUDU_LOGOUT_SENDER_INTERVAL_SECONDS
(default 15) plus a tenth as jitter, or as the one-shot command above. It
reports the same shape send-mail does: {"ran":true,"delivered":N,"failed":N},
or {"ran":false,"reason":"no tenant was enumerated"} on a database nobody
has seeded yet.
Like the outbox it takes no lock and needs ODUDU_APP_DATABASE_URL for the
same reason, declining to start without it the same way — and with
ODUDU_LOGOUT_SENDER_ENABLED=false and nothing scheduling the command, an
ended session's relying parties are never told, the way frontchannel_logout_uri
already isn't when a redirect fires instead of the logout page rendering
(ADR 0034). A relying party that accepts the connection and never answers
costs one delivery, not the queue: ODUDU_LOGOUT_SENDER_RESPONSE_TIMEOUT_MS
(default 5000) bounds each one independently, and every 4xx response
except 429 — the relying party rejecting the token outright, or refusing
it for a reason retrying will not fix — is abandoned rather than retried;
429 and anything else recoverable is retried, up to the source's own
BACKCHANNEL_LOGOUT_MAX_ATTEMPTS ceiling, which has no environment
variable of its own.
Querying the same row again shows why, in the queue's own words:
client_id | endpoint | attempts | delivered_at | last_error
----------------+------------------------------------+----------+--------------+-----------------------------------------
reports-widget | https://127.0.0.1:9443/backchannel | 1 | | address 127.0.0.1 is a loopback address
(1 row)
assertPublicAddress refuses 127.0.0.0/8 unconditionally, ahead of any
override — the same guard clientKeySet's jwks_uri fetch uses (ADR 0028).
attempts reads 1: claimDue spends the attempt when it claims the row, and
markFailed records only the error and when the row is next due, so
BACKCHANNEL_LOGOUT_MAX_ATTEMPTS is the number of times a token is really
offered. The pass and this second query are from after markFailed stopped
spending a second attempt: reports-widget created in its demo through POST /clients with this back-channel URI, ada signed in through it and signed out
through the confirmation page, as One sign-in's audit
trail does, then the
same query, which answered the first query's row above byte for byte, the same
pass and the same query again.
A private-range address is a different branch of that same guard, and one an
operator can open. ODUDU_ALLOW_PRIVATE_CLIENT_URLS — already read at boot
for jwks_uri — now reaches createLogoutDeliveryTransport too, at both call
sites that build one. With it set, delivery to a real listener actually happens.
Continuing this same stack: the container was recreated with
ODUDU_ALLOW_PRIVATE_CLIENT_URLS=true on the environment (docker compose up -d), a plain Node https listener was started on this host's own LAN address
(192.168.1.71:9444, standing in for a relying party — a private address is
exactly what this variable exists to admit; a public one needs no such
override), its self-signed certificate was copied into the container (docker cp cert.pem docker-odudu-1:/tmp/listener-cert.pem), and reports-widget's
backchannel_logout_uri was pointed at it instead:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config SET backchannel_logout_uri = 'https://192.168.1.71:9444/backchannel'
FROM clients WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'reports-widget';
"
A fresh sign-in, second-client authorization and logout — the same three
steps as above — enqueue one row exactly as before: the earlier row's own
endpoint is a snapshot taken at logout, unaffected by this UPDATE, so
only a fresh session's delivery targets the new address. odudu send-logouts, run with NODE_EXTRA_CA_CERTS pointed at the copied
certificate — the standard Node mechanism for trusting a root beyond the
default store, needed because the transport passes no ca option in
production and so falls back to it:
docker compose -f infra/docker/compose.yaml exec -T \
-e NODE_EXTRA_CA_CERTS=/tmp/listener-cert.pem odudu node dist/main.js send-logouts
{"ran":true,"delivered":1,"failed":0}
The listener's own log shows the request actually arrived:
POST /backchannel content-type=application/x-www-form-urlencoded body=logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxYTBjYjA3LTRlNzMtN2M1NS04Y2Y2LTRkMTY1OTVmN2EyOCIsInR5cCI6ImxvZ291dCtqd3QifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjMwMDAvdGVuYW50cy9kZW1vIiwiYXVkIjoicmVwb3J0cy13aWRnZXQiLCJpYXQiOjE3OTAxMTQzODcsImV4cCI6MTc5MDExNDUwNywianRpIjoiMDFhMGNiMjEtZDdiOS03YmJiLTgwNGItZDE3NmJmZGYzNjdjIiwic3ViIjoiMDFhMGNiMDctNGU3Ni03ZmJlLThkODYtYjdjMDgzMzE5ZWYwIiwic2lkIjoiMDFhMGNiMjEtZDcxMy03Y2NkLWE2NjktYzAxYzFlYjI4ZWU0IiwiZXZlbnRzIjp7Imh0dHA6Ly9zY2hlbWFzLm9wZW5pZC5uZXQvZXZlbnQvYmFja2NoYW5uZWwtbG9nb3V0Ijp7fX19.kki0ioQoGA1xaiCQz-hRNrKyVjqA815Ke5orwat7vTck6A7Ax_jrx8LSlCSCmzHgx7WRtqE2SiA0HmzUQlUFQh0GPzQ7qTzgCI5jrIPCzRqzWUNIzUKprzDBJL6b_FKe5OY4QgshJAxVBmdXRQxr5QW85-INuO90oFqmHQp_kMd7pXEpZpAVdyzqS8STI5Fkq85f-pvImq1UQcZJNwJx39PqwUEf7sY1Bx2ioiQ0L5hOAHL0M_jxZRGExRecy0uhX4zx6WRY6Yw_8-buClwQVPXq6MtPkaZwMJqFS3ijAR0FjmT-c0TE0hz-EgMRIwz8IOpjDvOrfcrot2vJ2b1lBg
Its header decodes to {"alg":"RS256","kid":"01a0cb07-…","typ":"logout+jwt"}
and its payload to {"iss":"http://localhost:3000/tenants/demo","aud":"reports-widget", "iat":1790114387,"exp":1790114507,"jti":"01a0cb21-…","sub":"01a0cb07-…", "sid":"01a0cb21-…","events":{"http://schemas.openid.net/event/backchannel-logout":{}}}
— every §2.4 member the clause table claims, on a token this walkthrough's
own listener actually received. The queue shows the same thing from the
other side — the same query as above, scoped to this endpoint instead,
since the refused loopback row is still in the table beside it:
docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
SELECT c.client_id, d.endpoint, d.attempts, d.delivered_at, d.last_error
FROM backchannel_logout_deliveries d JOIN clients c ON c.id = d.client_id
WHERE c.client_id = 'reports-widget' AND d.endpoint LIKE '%192.168%';
"
client_id | endpoint | attempts | delivered_at | last_error
----------------+---------------------------------------+----------+----------------------------+------------
reports-widget | https://192.168.1.71:9444/backchannel | 1 | 2026-09-22 21:59:48.213+00 |
(1 row)
attempts reads 1 here, the one offer that was delivered: the claim spends
the attempt, and markDelivered records the success.
Discovery now advertises backchannel_logout_supported,
backchannel_logout_session_supported, frontchannel_logout_supported
and frontchannel_logout_session_supported — all four unconditionally
true for every tenant, since a client opts in per client rather than per
tenant (see Discovery above).
Offline access
offline_access is a scope, seeded into every tenant alongside
openid/profile/email and assigned to demo-spa too — the one scope here
assigned 'optional' rather than 'default', which is what lets the consent
screen (below) tell it apart from a scope pre-approved
the moment a client is assigned it (it maps no claims either way — see
Discovery above).
demo-spa's own consent_required is false, because seed client leaves it off
unless it is given --consent-required
(PATCH /admin/tenants/{tenant}/clients/{id} changes it later, without If-Match,
being no list field) — so the transcript below reuses without ever seeing that
screen; the consent section demonstrates asking, against an anonymously
self-registered client, whose consent_required defaults true (ADR 0027, and
the registration section
above). Requesting it produces a grant with no session, which is what nothing
here can expire and no logout can end (OpenID Connect Back-Channel Logout 1.0
§2.7's second sentence, the back-channel logout reading
note).
A fresh login, keeping its cookie, redeems one code for a plain scope=openid
grant and then reuses the same live session — no new login — to redeem a second
code for scope=openid offline_access:
VERIFIER=$(openssl rand -hex 32)
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr '+/' '-_' | tr -d '=')
AUTH_SESSION_ID=$(curl -sS --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid' \
--data-urlencode 'state=xyz-bound' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" | sed -n '/name="auth_session_id"/{s/.*value="\([^"]*\)".*/\1/p;q;}')
CODE=$(curl -sS -c cookies-offline.txt -D - -o /dev/null \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery' \
"$LOGIN" | sed -n 's/.*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')
BOUND_TOKENS=$(curl -sS \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" "$BASE/token")
BOUND_REFRESH_TOKEN=$(printf '%s' "$BOUND_TOKENS" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
OFFLINE_CODE=$(curl -sS -b cookies-offline.txt -D - -o /dev/null --get \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'scope=openid offline_access' \
--data-urlencode 'state=xyz-offline' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
"$BASE/auth" \
| sed -n 's/.*[Ll]ocation: .*[?&]code=\([^&[:space:]]*\).*/\1/p' | tr -d '\r')
OFFLINE_TOKENS=$(curl -sS \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode "code=$OFFLINE_CODE" \
--data-urlencode 'redirect_uri=http://localhost:8080/callback' \
--data-urlencode 'client_id=demo-spa' \
--data-urlencode "code_verifier=$VERIFIER" "$BASE/token")
OFFLINE_REFRESH_TOKEN=$(printf '%s' "$OFFLINE_TOKENS" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"id_token": "eyJhbGciOiJSUzI1NiIs…",
"refresh_token": "nAnQ0qDYIZyOInKkB2H…",
"token_type": "Bearer",
"expires_in": 300,
"scope": "openid offline_access"
}
(All three token values truncated.) The access token, decoded:
{
"iss": "http://localhost:3000/tenants/demo",
"sub": "01a0cb07-4e76-…",
"aud": ["http://localhost:3000/tenants/demo"],
"client_id": "demo-spa",
"scope": "openid offline_access",
"iat": 1790114491,
"exp": 1790114791,
"jti": "01a0cb23-6bde-…",
"grant_id": "01a0cb23-6bde-7b79-83f6-673a0f0a0226"
}
No sid — every other access token in this document carries one (the
back-channel logout reading
note
§2.1), and this is the one grant here with no session for it to name. grant_id
carries no such exception; it names the grant itself, not a session, so it is on
this token exactly as it is on every other. The ID token, decoded, is missing
sid the same way, but still carries amr and acr:
{
"sub": "01a0cb07-4e76-…",
"iss": "http://localhost:3000/tenants/demo",
"aud": "demo-spa",
"iat": 1790114491,
"exp": 1790114791,
"amr": ["pwd"],
"acr": "1"
}
amr/acr read the session the login itself established
(code.sessionId), not the grant's own, offline-nulled session binding —
the two are different fields for exactly this reason: an offline_access
grant has no session to end at logout, but it is still a statement about a
login that really happened, with a real authentication behind it.
Logging out the session that redeemed both codes ends it the same two-step
way shown above: GET for the confirmation page, then the hidden
session_id posted back to confirm.
curl -sS -b cookies-offline.txt "http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign out?</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Sign out?</h1>
<p>Signing out ends this session for every application that uses it.</p>
<form method="post" action="/tenants/demo/protocol/openid-connect/logout">
<input type="hidden" name="session_id" value="01a0cb23-6b57-…">
<button type="submit">Sign out</button>
</form>
</main>
</body>
</html>
(session_id shortened, as elsewhere in this document.)
curl -sS -b cookies-offline.txt -D - \
--data-urlencode 'session_id=01a0cb23-6b57-7ed4-9d1c-065e8242dda7' \
"http://localhost:3000/tenants/demo/protocol/openid-connect/logout"
HTTP/1.1 200 OK
set-cookie: demo-session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
cache-control: no-store
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Signed out</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<h1>Signed out</h1>
<p>You have been signed out.</p>
</main>
</body>
</html>
The session-bound refresh token is revoked with it:
curl -sS \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$BOUND_REFRESH_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
"$BASE/token"
{ "error": "invalid_grant", "error_description": "The code or token is not valid." }
The offline one is not — nothing about ending the session touched a grant with none:
curl -sS \
--data-urlencode 'grant_type=refresh_token' \
--data-urlencode "refresh_token=$OFFLINE_REFRESH_TOKEN" \
--data-urlencode 'client_id=demo-spa' \
"$BASE/token"
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"refresh_token": "ACz3TGdmqj05ZrH738L…",
"token_type": "Bearer",
"expires_in": 300,
"scope": "openid offline_access"
}
(Both token values truncated.) A session-bound refresh token dies the moment its
session does, whether that session was ended by this logout or by its own idle
timeout expiring underneath it — refresh rotation checks the session's own
liveness, not just the grant's revoked_at, for exactly that second case. An
offline grant has neither: no session to end, and no idle window to outlive, so
it is bounded only by its own refresh_token_ttl_seconds and by retention (see
the roadmap) —
nothing about ending a session ages it out early. An administrator revokes one
with DELETE /admin/tenants/{tenant}/subjects/{id}/grants/{clientId}, or every
grant a client holds with DELETE /admin/tenants/{tenant}/clients/{id}/grants
(Admin
paths).
A client is only handed this scope if the tenant's operator assigned it —
demo-spa has it because odudu seed assigns every default scope,
offline_access included, though 'optional' rather than 'default'; a
request for it from a client whose assignment was withdrawn between /authorize
accepting the request and the code being redeemed gets an ordinary session-bound
grant back instead, since the resolved scope at redemption is the authority, not
the request /authorize saw. A client never assigned the scope at all is
refused outright, with invalid_scope, before a code is ever issued — the same
rule any other unassigned scope gets (see
Discovery above).
The consent screen
The scopes it lists are ordered by each scope's display_order, then its
name, and each is shown by the scope's consent_text where an
administrator gave it one (the admin paths, under
PATCH /scopes/:id); the scopes below have neither, so they show by name.
demo-spa's own consent_required is false, so nothing above ever saw
this screen. It exists for a client whose consent_required is true —
every anonymously self-registered client (consent_required defaults
true there, per Dynamic client registration
and ADR 0027) — and for prompt=consent on any client at all.
The screen and the reuse and prompt=none answers below come from a stack of
its own on http://localhost:3094, started on an empty volume, with demo
seeded the way Bootstrap seeds it. The subsections from "The
client's own pages" on come from an earlier stack, whose transcripts show no
response header. The refused consent and the prompt=none refusal were run again
on a later stack, on http://localhost:3199, against a client made from the
command line, odudu seed client --tenant demo --client-id consent-spa --public --redirect-uri https://rp.example/cb --consent-required, with ada in demo;
$CLIENT_ID is consent-spa there. The prompt=none answer follows an Allow
that left offline_access unticked, so that scope is the one missing. client_registration_policy is disabled on demo by
default, the same as every tenant, so the anonymously self-registered client
this section needs it open:
odudu seed tenant --name demo --set client_registration_policy=open
{
"command": "tenant",
"created": false,
"tenant": "demo",
"tenantId": "01a137d6-\u2026",
"settings": ["client_registration_policy"]
}
Then the client itself, with no credential presented — registration_origin
is 'anonymous' and consent_required defaults true on the row it wrote:
curl -sS -X POST http://localhost:3094/tenants/demo/clients-registrations/openid-connect \
-H 'content-type: application/json' \
-d '{"redirect_uris":["https://rp.example/cb"],"client_name":"Example RP"}'
{
"client_id": "01a137d6-c1b7-76fa-92b1-bb33828ae56f",
"client_id_issued_at": 1791938183,
"client_secret": "zEOnBPiO5AnubSnAQ3nQTq_DHRsIvOd4cXTTXEGp-lw",
"client_secret_expires_at": 0,
"redirect_uris": ["https://rp.example/cb"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "client_secret_basic",
"client_name": "Example RP"
}
A client that requires consent renders the screen once the credentials
that would otherwise complete the login have been accepted — after the
same required-action gate /authorize's form path always enforced, and
before a code is ever issued. PKCE and the parked request are the same
shape the login POST uses, against this new client and
scope=openid profile offline_access, with state=xyz-123 and a cookie jar,
cookies-consent.txt, kept from /authorize onward:
curl -sS -D - -c cookies-consent.txt -b cookies-consent.txt \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=ada' \
--data-urlencode 'password=correct-horse-battery' \
'http://localhost:3094/tenants/demo/login-actions/authenticate'
HTTP/1.1 200 OK
x-request-id: 01a137d6-c260-716b-9777-461155c440c8
content-type: text/html
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self' https://rp.example; base-uri 'none'
x-frame-options: DENY
referrer-policy: no-referrer
cache-control: no-store
x-content-type-options: nosniff
content-language: en
content-length: 1453
Date: Wed, 14 Oct 2026 00:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Allow access?</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1><bdi>Example RP</bdi> is asking for access</h1>
<p class="tenant"><bdi>demo</bdi></p>
</header>
<form method="post" action="/tenants/demo/login-actions/consent">
<input type="hidden" name="auth_session_id" value="01a137d6-c22e-791c-9097-e8d295b70e7f">
<ul>
<li><bdi>openid</bdi></li>
<li><bdi>profile</bdi></li>
</ul>
<label><input type="checkbox" name="scope" value="offline_access"> <bdi>offline_access</bdi> — grants ongoing access, even while you are not present</label>
<button type="submit" name="decision" value="allow">Allow</button>
<button type="submit" name="decision" value="deny" class="secondary">Deny</button>
</form>
</main>
</body>
</html>
No set-cookie, no location: nothing is established and no code is
issued until the form below is answered. offline_access is the one scope
here that carries the explanatory clause OIDC Core §16.18 asks for — every
default scope (openid, profile) needs no box at all, since the client
was already assigned it without asking. The page's form-action names the
client's https://rp.example because answering it ends in the redirect
there.
Declining the optional scope narrows what the eventual token carries —
scope in the token response omits offline_access, not merely "the flow
completed" — and the client's answer is what gets recorded, so the same
request does not ask again. Leaving the checkbox unticked and pressing
Allow:
curl -sS -D - -c cookies-consent.txt -b cookies-consent.txt \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'decision=allow' \
'http://localhost:3094/tenants/demo/login-actions/consent'
HTTP/1.1 302 Found
x-request-id: 01a137d6-c2c7-78cf-8e8d-1b9762557de3
set-cookie: demo-session=01a137d6-c2e4-7f70-8fc3-6dbee704dde9:a-7981Ir1Wexv7gTDwGcUtsV2iJBTzlNF6CaRh_DHds; HttpOnly; SameSite=Lax; Path=/
set-cookie: demo-session-persistent=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0
location: https://rp.example/cb?code=NreYk9SzpC5ttZWW-ylIReNrfpOulmUMp4DXbH8_JDk&state=xyz-123&iss=http%3A%2F%2Flocalhost%3A3094%2Ftenants%2Fdemo
content-length: 0
Date: Wed, 14 Oct 2026 00:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=72
The ID token that code redeems for says how ada authenticated, exactly as a login with no consent screen would. The consent door reads the factors the login recorded on its authentication session, and the password that finished it is one of them:
curl -sS -X POST http://localhost:3094/tenants/demo/protocol/openid-connect/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=NreYk9SzpC5ttZWW-ylIReNrfpOulmUMp4DXbH8_JDk \
--data-urlencode 'redirect_uri=https://rp.example/cb' \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The id_token's payload, decoded:
{
"sub": "01a137d6-bf12-771f-81fb-97620b5fd349",
"name": "ada",
"preferred_username": "ada",
"iss": "http://localhost:3094/tenants/demo",
"aud": "01a137d6-c1b7-76fa-92b1-bb33828ae56f",
"iat": 1791938184,
"exp": 1791938484,
"sid": "01a137d6-c2e4-7f70-8fc3-6dbee704dde9",
"amr": ["pwd"],
"acr": "1"
}
Until 2026-10-05 a login through this screen issued neither claim, because the factor that finished it was never written down for the consent POST to read.
Pressing Deny instead answers exactly where a client-side access_denied
always does — the request's own redirect_uri, not a page — with nothing
established and no code issued. (A fresh parked request, state=xyz-deny,
logged in the same way as above — the one that redeemed a code cannot
answer twice.)
curl -sS -D - \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'decision=deny' \
'http://localhost:3199/tenants/demo/login-actions/consent'
HTTP/1.1 302 Found
x-request-id: 01a167e4-dcea-7c79-b582-1b02ba377c8f
location: https://rp.example/cb?error=access_denied&error_description=The+user+did+not+allow+the+request.&state=xyz-deny&iss=http%3A%2F%2Flocalhost%3A3199%2Ftenants%2Fdemo
content-length: 0
Date: Fri, 23 Oct 2026 08:33:34 GMT
Connection: keep-alive
Keep-Alive: timeout=72
The gate applies to a live SSO session exactly as it does to a fresh login,
which is the reason this section exists rather than being folded into the
login-form walkthrough above: a second /authorize request, against the same
cookie, asking for a wider scope than what the first consent recorded, renders
this same screen again rather than reusing the session straight through to a
code — a test named asks for consent on a reused SSO session, not only on a fresh login is the case that would ship broken if the gate lived only on the
form path. The cookie from Allow above (openid profile, offline_access
declined) asking again with offline_access in scope, this time with no login
form in between — the session is still live:
curl -sS -D - -b cookies-consent.txt --get \
--data-urlencode 'response_type=code' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode 'redirect_uri=https://rp.example/cb' \
--data-urlencode 'scope=openid profile offline_access' \
--data-urlencode 'state=xyz-reuse' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
'http://localhost:3094/tenants/demo/protocol/openid-connect/auth'
HTTP/1.1 200 OK
x-request-id: 01a137d6-c485-73aa-b3c3-824d47fe7178
content-type: text/html
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self' https://rp.example; base-uri 'none'; script-src 'nonce-RGkC4LknpA2uMvjtQxZIHg=='
x-frame-options: DENY
referrer-policy: no-referrer
cache-control: no-store
x-content-type-options: nosniff
content-language: en
content-length: 1602
Date: Wed, 14 Oct 2026 00:36:24 GMT
Connection: keep-alive
Keep-Alive: timeout=72
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Allow access?</title>
<link rel="stylesheet" href="/instrument/instrument.css">
<link rel="icon" href="/instrument/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/instrument/favicon.ico" sizes="48x48">
</head>
<body>
<main class="page">
<p class="brand"><svg class="mark" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><circle cx="16" cy="6" r="4"/><path d="M16 10V30"/><path d="M12 14H20"/></svg><span class="wordmark">odudu</span></p>
<header class="intro">
<h1><bdi>Example RP</bdi> is asking for access</h1>
<p class="tenant"><bdi>demo</bdi></p>
</header>
<form method="post" action="/tenants/demo/login-actions/consent">
<input type="hidden" name="auth_session_id" value="01a137d6-c495-744e-aefc-7c457bb799de">
<ul>
<li><bdi>openid</bdi></li>
<li><bdi>profile</bdi></li>
</ul>
<label><input type="checkbox" name="scope" value="offline_access"> <bdi>offline_access</bdi> — grants ongoing access, even while you are not present</label>
<button type="submit" name="decision" value="allow">Allow</button>
<button type="submit" name="decision" value="deny" class="secondary">Deny</button>
</form>
</main>
<script nonce="RGkC4LknpA2uMvjtQxZIHg==">window.addEventListener('pageshow', function (event) { if (event.persisted) location.reload(); });</script>
</body>
</html>
(The same form the login POST produced above under a fresh
auth_session_id, with two differences that come from this being a GET: the
policy gains script-src naming a nonce, and the one <script> carrying it
follows the form, reloading the page if a browser restores it from its cache
— a reload would re-send the POST that produced the page above, so that
page has none. A second /authorize, not a POST, is what got here, because
the SSO cookie skipped the login form entirely.) Under
prompt=none, that same reused-but-under-consented session is refused
rather than asked, since prompt=none forbids the interaction a consent
screen is:
curl -sS -D - -b cookies-consent.txt --get \
--data-urlencode 'response_type=code' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode 'redirect_uri=https://rp.example/cb' \
--data-urlencode 'scope=openid profile offline_access' \
--data-urlencode 'state=xyz-reuse' \
--data-urlencode 'prompt=none' \
--data-urlencode "code_challenge=$CHALLENGE" \
--data-urlencode 'code_challenge_method=S256' \
'http://localhost:3199/tenants/demo/protocol/openid-connect/auth'
HTTP/1.1 302 Found
x-request-id: 01a167e4-f9ca-701f-a03b-dcf5e4dad8b7
location: https://rp.example/cb?error=consent_required&error_description=The+user+has+not+agreed+to+this+request+yet.&state=xyz-reuse&iss=http%3A%2F%2Flocalhost%3A3199%2Ftenants%2Fdemo
content-length: 0
Date: Fri, 23 Oct 2026 08:33:41 GMT
Connection: keep-alive
Keep-Alive: timeout=72
The client's own pages
A client that registered RFC 7591's client_uri, policy_uri or tos_uri has
each linked under its name, so the End-User can read them before answering.
Every link is escaped through the renderer's own escapeHtml, attribute and
text alike, and the page's policy is the one every continuing page sends: a link
is a navigation, which default-src 'none' does not govern. On the earlier
stack named above, with a tenant of its own, email-demo, holding grace
(odudu seed tenant --name email-demo, then odudu seed user with an address),
$ADMIN_TOKEN got the way the admin
paths shows, and a public client created
through the admin API for it:
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"client_id": "billing-spa", "name": "Billing & Co", "token_endpoint_auth_method": "none", "redirect_uris": ["http://localhost:8080/callback"], "consent_required": true, "client_uri": "https://billing.example/about?from=consent&lang=en", "policy_uri": "https://billing.example/privacy", "tos_uri": "https://billing.example/terms"}' \
http://localhost:3086/admin/tenants/email-demo/clients \
| jq -c '{client_id, name, consent_required, client_uri, policy_uri, tos_uri}'
PAGE=$(curl -sS 'http://localhost:3086/tenants/email-demo/protocol/openid-connect/auth?response_type=code&client_id=billing-spa&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&scope=openid&state=s&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256')
AUTH_SESSION_ID=$(echo "$PAGE" | sed -n 's/.*name="auth_session_id" value="\([^"]*\)".*/\1/p' | head -1)
curl -sS -D - \
--data-urlencode "auth_session_id=$AUTH_SESSION_ID" \
--data-urlencode 'username=grace' \
--data-urlencode 'password=correct-horse-battery' \
http://localhost:3086/tenants/email-demo/login-actions/authenticate \
| tr -d '\r' | sed -n '1p;/^content-security-policy/p;/<h1>/,/<form/p'
{"client_id":"billing-spa","name":"Billing & Co","consent_required":true,"client_uri":"https://billing.example/about?from=consent&lang=en","policy_uri":"https://billing.example/privacy","tos_uri":"https://billing.example/terms"}
HTTP/1.1 200 OK
content-security-policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; form-action 'self' http://localhost:8080; base-uri 'none'
<h1><bdi>Billing & Co</bdi> is asking for access</h1>
<p><a href="https://billing.example/about?from=consent&lang=en">About <bdi>Billing & Co</bdi></a> · <a href="https://billing.example/privacy">Privacy policy</a> · <a href="https://billing.example/terms">Terms of service</a></p>
<form method="post" action="/tenants/email-demo/login-actions/consent">