コンテンツにスキップ

バックチャネルログアウト

概要

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 | undefined

extractHeader()

署名アルゴリズムと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です:

クレーム必須説明
issstringはい発行者識別子
substring条件付きサブジェクト(ユーザー)識別子。sid が存在しない場合は必須
audstring | string[]はいオーディエンス — リソースサーバー
iatnumberはい発行時刻のタイムスタンプ
jtistringはい一意なトークン識別子(リプレイ保護用)
expnumberはい有効期限のタイムスタンプ
eventsobjectはいバックチャネルログアウトイベントURIを含む必要がある
sidstring条件付きセッション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仕様に基づき、以下のルールを適用します:

  1. issexpectedIssuer と一致する必要がある
  2. audexpectedAudience を含む必要がある
  3. eventsBACKCHANNEL_LOGOUT_EVENT を含む必要がある
  4. sub または sid — 少なくとも1つが存在する必要がある
  5. jti が存在する必要がある
  6. exp が存在し、期限切れでない必要がある
  7. 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;

次のステップ