Receive back-channel logout
Receive back-channel logout
When a person signs out, when an administrator ends their sessions, when their account is deactivated, or when they are removed from an organization, every application that signed them in is told — by a signed logout token POSTed from the server to the application (OpenID Connect Back-Channel Logout 1.0). The application ends its own session for them. It does not need to re-check the person's grants on every request to find out.
There are two halves: the host that runs this package names the session a sign-in came from, and each application registers an endpoint and validates what arrives there.
The host: name the session
The ID Token's sid and the record of which applications a session reached both come from
one argument. Pass the session id when you mint the authorization code:
use Cbox\Id\OAuthServer\Contracts\AuthorizationCodes;
$code = app(AuthorizationCodes::class)->issue(
$clientId,
$userId,
$organizationId,
$redirectUri,
$scopes,
$codeChallenge,
'S256',
$nonce,
$session->created_at?->getTimestamp(),
$session->amr,
$resource,
sessionId: $session->id, // the SessionManager session the person approved from
);
Without it everything still works, and the application is still reachable by sub when
the person signs out everywhere or loses access — but no ID Token carries sid, and ending
one session cannot name it.
Sessions ended through SessionManager — revoke() and revokeAllForUser() — notify the
applications on their own. So do RefreshTokens::withdrawAccess() /
revokeForUserAndClient(), Subjects::deactivate(), and removing a membership.
RefreshTokens::revokeForUser() does not: it is the claims-freshness lever a host pulls on
every role change, and the person has not lost access, so no application is told. A host that
ends sessions some other way calls the contract itself:
use Cbox\Id\OAuthServer\Contracts\BackchannelLogout;
app(BackchannelLogout::class)->sessionEnded($sessionId); // one session
app(BackchannelLogout::class)->subjectSignedOut($userId); // every session they hold
These only record and queue: delivery runs on a queue worker (see Background work), so an application that is down never slows a sign-out.
RP-initiated logout without an id_token_hint
/oauth/logout without a verifiable hint may only sign out this browser. The package
cannot know where your host keeps this browser's session id, so by default it clears the
Laravel session and leaves the session row alone — and then no application hears about it.
Bind SignedInSession and that logout ends the session properly:
use Cbox\Id\Identity\Contracts\SignedInSession;
$this->app->singleton(SignedInSession::class, MySignedInSession::class);
class MySignedInSession implements SignedInSession
{
public function id(): ?string
{
// Only from what this browser has already proven — never a request parameter.
return session('platform.session_id');
}
}
Register the application
use Cbox\Id\OAuthServer\Contracts\ClientRegistry;
app(ClientRegistry::class)->configureBackchannelLogout(
$client,
'https://app.example.com/auth/backchannel-logout',
sessionRequired: false,
);
Or at registration: NewClient(backchannelLogoutUri: …, backchannelLogoutSessionRequired: …),
or backchannel_logout_uri / backchannel_logout_session_required in a Dynamic Client
Registration request. The URI must be https:// (plain http:// only on localhost), with
no fragment and no credentials. It is checked again at delivery by the SSRF guard: a host
that resolves to a private, loopback or metadata address is never called, so a
localhost URI is only reachable with CBOX_ID_BACKCHANNEL_LOGOUT_VERIFY_URL=false, in
development.
Set sessionRequired when your application can only end a session it can name. Such an
application receives one token per session, always with sid, and never a subject-only one.
The application: validate and end the session
The request is a POST with Content-Type: application/x-www-form-urlencoded and one
field, logout_token. Validate it the way you validate an ID Token, plus the logout-specific
checks (§2.6):
- Verify the signature against the issuer's JWKS (
/.well-known/jwks.json), with the algorithm pinned toRS256— the same key and algorithm as the ID Token. issis the issuer you signed in against;audis yourclient_id.iatis present and recent;exphas not passed (tokens live two minutes).typin the header islogout+jwt.eventscontains the memberhttp://schemas.openid.net/event/backchannel-logout.sub,sid, or both are present.- There is no
nonce— a token with one is refused, so an ID Token can never be replayed here. jtihas not been seen before (keep it untilexp).
Then end the session: by sid when present, otherwise every session of sub. Answer
200 (or 204) with Cache-Control: no-store; answer 400 for a token you refused.
A Laravel application using firebase/php-jwt:
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
Route::post('/auth/backchannel-logout', function (Request $request) {
$issuer = config('services.cbox_id.issuer'); // e.g. https://id.example.com
$clientId = config('services.cbox_id.client_id');
$refuse = fn (string $why) => response()->json(['error' => 'invalid_request', 'error_description' => $why], 400)
->header('Cache-Control', 'no-store');
$token = (string) $request->input('logout_token');
try {
$jwks = Cache::remember('cbox-id.jwks', 3600, fn () => Http::get($issuer.'/.well-known/jwks.json')->throw()->json());
$claims = (array) JWT::decode($token, JWK::parseKeySet($jwks, 'RS256')); // signature, exp, iat
} catch (\Throwable) {
return $refuse('invalid token');
}
$header = json_decode(JWT::urlsafeB64Decode(explode('.', $token)[0]), true);
$events = (array) ($claims['events'] ?? []);
if (($header['typ'] ?? null) !== 'logout+jwt'
|| ($claims['iss'] ?? null) !== $issuer
|| ! in_array($clientId, (array) ($claims['aud'] ?? []), true)
|| ! array_key_exists('http://schemas.openid.net/event/backchannel-logout', $events)
|| (! isset($claims['sub']) && ! isset($claims['sid']))
|| array_key_exists('nonce', $claims)) {
return $refuse('not a logout token for this client');
}
// Replay: each jti once, for as long as the token could be valid.
if (! Cache::add('backchannel-jti:'.$claims['jti'], true, 300)) {
return $refuse('replayed');
}
// Your session store: you recorded `sid` (and `sub`) from the ID Token at sign-in.
isset($claims['sid'])
? MySessions::endBySid($claims['sid'])
: MySessions::endAllForSubject($claims['sub']);
return response('', 200)->header('Cache-Control', 'no-store');
});
Record sid at sign-in. Store the ID Token's sid and sub against the session you
create. That is what endBySid looks up — the logout token carries no other reference to
your session. On Laravel's database session driver this is typically a column or a small
table from sid to your session ids.
Exclude the route from CSRF. The request comes from a server, with no session and no
token. Exclude the path in bootstrap/app.php —
$middleware->validateCsrfTokens(except: ['auth/backchannel-logout']) — rather than with
withoutMiddleware(VerifyCsrfToken::class) on the route, which does not match the
middleware Laravel 11+ actually registers and so excludes nothing.
When a delivery fails
Every final outcome is in the environment's audit trail: oauth.backchannel_logout.delivered,
or oauth.backchannel_logout.failed with the reason — HTTP 400: invalid_request,
connection failed: …, backchannel_logout_uri refused by the SSRF guard: …. Timeouts,
5xx, 408 and 429 are retried (10 s, 1 min, 5 min, 15 min…, five attempts by default);
any other 4xx, a redirect, or an SSRF refusal is final.