バックチャネルログアウト
概要
OIDC Back-Channel Logout 1.0では、ユーザーがログアウトした際に認可サーバーがリソースサーバーに直接通知できます。ユーザーのブラウザを介してログアウトを伝播する代わりに、認可サーバーが署名済みのログアウトトークン(JWT)をバックエンドエンドポイントに送信します。
@authrim/server は、これらのトークンを解析・検証するための BackChannelLogoutValidator クラスを提供します。
ログアウトフロー
sequenceDiagram
participant User
participant AS as Authorization Server
participant RS as Your Resource Server
participant DB as Session Store
User->>AS: Logout request
AS->>RS: POST /backchannel-logout
(logout_token JWT)
RS->>RS: BackChannelLogoutValidator
.validate(logoutToken)
RS->>RS: Verify JWT signature
(your responsibility)
RS->>RS: Check jti replay
(your responsibility)
RS->>DB: Invalidate session(s)
RS-->>AS: 200 OK
AS-->>User: Logout complete
BackChannelLogoutValidator
初期化
import { BackChannelLogoutValidator } from '@authrim/server';
const validator = new BackChannelLogoutValidator();validate()
ログアウトトークンを検証し、構造化された結果を返します:
import { BackChannelLogoutValidator } from '@authrim/server';import type { BackChannelLogoutValidationOptions, BackChannelLogoutValidationResult,} from '@authrim/server';
const validator = new BackChannelLogoutValidator();
const options: BackChannelLogoutValidationOptions = { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 30,};
const result: BackChannelLogoutValidationResult = validator.validate( logoutToken, options,);
if (result.valid) { // result.claims contains LogoutTokenClaims console.log('Subject:', result.claims.sub); console.log('Session ID:', result.claims.sid);} else { console.error('Validation failed:', result.error);}extractClaims()
完全な検証を行わずにJWTペイロードを抽出します。ロギングやデバッグに便利です:
const claims = validator.extractClaims(logoutToken);// claims: LogoutTokenClaims | undefinedextractHeader()
署名アルゴリズムとKey IDを確認するためにJWTヘッダーを抽出します:
const header = validator.extractHeader(logoutToken);// header: { alg: string; kid?: string; typ?: string }検証オプション
interface BackChannelLogoutValidationOptions { /** Expected issuer (iss claim). Must match exactly. */ expectedIssuer: string;
/** Expected audience (aud claim). Token must include this value. */ expectedAudience: string;
/** Clock tolerance in seconds for iat/exp checks. Default: 0 */ clockToleranceSeconds?: number;}ログアウトトークンの構造
バックチャネルログアウトトークンは以下のクレームを含むJWTです:
| クレーム | 型 | 必須 | 説明 |
|---|---|---|---|
iss | string | はい | 発行者識別子 |
sub | string | 条件付き | サブジェクト(ユーザー)識別子。sid が存在しない場合は必須 |
aud | string | string[] | はい | オーディエンス — リソースサーバー |
iat | number | はい | 発行時刻のタイムスタンプ |
jti | string | はい | 一意なトークン識別子(リプレイ保護用) |
exp | number | はい | 有効期限のタイムスタンプ |
events | object | はい | バックチャネルログアウトイベントURIを含む必要がある |
sid | string | 条件付き | セッションID。sub が存在しない場合は必須 |
eventsクレーム
events クレームにはOIDCバックチャネルログアウトイベントが含まれている必要があります:
import { BACKCHANNEL_LOGOUT_EVENT } from '@authrim/server';
// BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
// Example events claim in a logout token:// { "http://schemas.openid.net/event/backchannel-logout": {} }検証ルール
バリデーターはOIDC Back-Channel Logout仕様に基づき、以下のルールを適用します:
issはexpectedIssuerと一致する必要があるaudはexpectedAudienceを含む必要があるeventsはBACKCHANNEL_LOGOUT_EVENTを含む必要があるsubまたはsid— 少なくとも1つが存在する必要があるjtiが存在する必要があるexpが存在し、期限切れでない必要があるnonceが存在してはならない(nonceクレームを含むトークンは拒否される)
アプリケーション側の責任
セッション無効化パターン
セッションID(sid)による無効化
ログアウトトークンに sid クレームが含まれている場合、その特定のセッションのみを無効化します:
async function invalidateBySessionId( sessionStore: SessionStore, sid: string,): Promise<void> { await sessionStore.deleteSession(sid);}サブジェクト(sub)による無効化
ログアウトトークンに sub クレームが含まれている場合(sid なし)、そのユーザーのすべてのセッションを無効化します:
async function invalidateBySubject( sessionStore: SessionStore, sub: string,): Promise<void> { const sessions = await sessionStore.findSessionsByUser(sub); await Promise.all( sessions.map((session) => sessionStore.deleteSession(session.id)), );}統合戦略
最大限の互換性のために両方のケースを処理します:
import type { LogoutTokenClaims } from '@authrim/server';
async function invalidateSessions( sessionStore: SessionStore, claims: LogoutTokenClaims,): Promise<void> { if (claims.sid) { // Prefer session-specific logout await sessionStore.deleteSession(claims.sid); } else if (claims.sub) { // Fall back to user-wide logout const sessions = await sessionStore.findSessionsByUser(claims.sub); await Promise.all( sessions.map((s) => sessionStore.deleteSession(s.id)), ); }}完全な例:Express
import express from 'express';import { createRemoteJWKSet, jwtVerify } from 'jose';import { BackChannelLogoutValidator, BACKCHANNEL_LOGOUT_EVENT } from '@authrim/server';
const app = express();app.use(express.urlencoded({ extended: false }));
const validator = new BackChannelLogoutValidator();
const JWKS = createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json'),);
app.post('/backchannel-logout', async (req, res) => { const logoutToken = req.body.logout_token;
if (!logoutToken) { return res.status(400).json({ error: 'missing logout_token' }); }
// Step 1: Verify JWT signature try { await jwtVerify(logoutToken, JWKS, { issuer: 'https://auth.example.com', audience: 'my-resource-server', }); } catch { return res.status(400).json({ error: 'invalid signature' }); }
// Step 2: Validate token structure and claims const result = validator.validate(logoutToken, { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 30, });
if (!result.valid) { return res.status(400).json({ error: result.error }); }
// Step 3: Check jti replay const isNew = await checkAndStoreJti(redis, result.claims.jti); if (!isNew) { return res.status(400).json({ error: 'replayed token' }); }
// Step 4: Invalidate sessions await invalidateSessions(sessionStore, result.claims);
return res.status(200).send();});完全な例:Hono
import { Hono } from 'hono';import { createRemoteJWKSet, jwtVerify } from 'jose';import { BackChannelLogoutValidator } from '@authrim/server';
const app = new Hono();const validator = new BackChannelLogoutValidator();
const JWKS = createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json'),);
app.post('/backchannel-logout', async (c) => { const body = await c.req.parseBody(); const logoutToken = body['logout_token'] as string;
if (!logoutToken) { return c.json({ error: 'missing logout_token' }, 400); }
// Verify JWT signature try { await jwtVerify(logoutToken, JWKS, { issuer: 'https://auth.example.com', audience: 'my-resource-server', }); } catch { return c.json({ error: 'invalid signature' }, 400); }
// Validate token structure and claims const result = validator.validate(logoutToken, { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 30, });
if (!result.valid) { return c.json({ error: result.error }, 400); }
// Check jti replay const isNew = await checkAndStoreJti(redis, result.claims.jti); if (!isNew) { return c.json({ error: 'replayed token' }, 400); }
// Invalidate sessions await invalidateSessions(sessionStore, result.claims);
return c.body(null, 200);});
export default app;次のステップ
- エラーハンドリング — 検証エラーの処理
- 設定リファレンス — プロバイダーシステムとランタイムオプション
- Express & Fastifyアダプター — フレームワーク統合
- Hono、Koa & NestJSアダプター — その他のフレームワークアダプター