auth-security について
auth-securityは、開発者向けにOAuth 2.1とJWTの実装指針をまとめたセキュリティスキルです。トークン管理と、一般的なWeb認証攻撃への対策を扱います。
主な機能
- 01PKCE必須のOAuth 2.1
- 02JWTアルゴリズムの選択と検証
- 03安全なトークン保存の指針
- 04リフレッシュトークンのローテーション
- 05CSRFとXSSへの対策
- 06トークン失効とDPoPの指針
活用シーン
- 01OAuth 2.1ログインフローの設計
- 02JWT署名と検証のレビュー
- 03ブラウザーのトークン保存方式の選択
- 04トークンのローテーションと失効の追加
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ majiayu000/ spellbook --skill auth-securityインストール後、Claude Code で/auth-securityを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
reference/attacks.mdjwt.mdoauth2.1.mdtemplates/typescript/auth.service.ts詳細
認証セキュリティ
基本原則
- OAuth 2.1 — RFC 9700(2025年1月)に準拠します
- PKCE 必須 — すべてのクライアントで PKCE を使用する必要があります
- 有効期間の短いトークン — アクセストークンの有効期限は5~15分です
- トークンローテーション — リフレッシュトークンは1回のみ使用できます
- HttpOnly ストレージ — ブラウザーのトークンは HttpOnly Cookie に保存します
- アルゴリズムを明示 — JWT ヘッダーのアルゴリズムを決して信頼しないでください
- 後方互換性なし — 非推奨の認証フローは削除します
OAuth 2.1 の主な変更点
非推奨のフロー(使用禁止)
| フロー | 状態 | 置き換え |
|---|---|---|
| Implicit Grant | 削除済み | Authorization Code + PKCE |
| Password Grant | 削除済み | Authorization Code + PKCE |
| PKCE なしの Auth Code | 削除済み | PKCE の使用が必須 |
必須:Authorization Code + PKCE
import crypto from 'crypto';
// 1. Generate code verifier (43-128 chars)
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
// 2. Generate code challenge
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
// 3. Authorization request
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateState());
// 4. Token exchange (after redirect)
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier, // Prove we initiated the request
}),
});
JWT のベストプラクティス
アルゴリズムの選択(2025年)
| 優先度 | アルゴリズム | 備考 |
|---|---|---|
| 1 | EdDSA (Ed25519) | 最も安全で、量子耐性の特性があります |
| 2 | ES256 (ECDSA P-256) | 広くサポートされ、署名がコンパクトです |
| 3 | PS256 (RSA-PSS) | RS256 より安全です |
| 4 | RS256 (RSA PKCS#1) | 最も互換性があります |
// Recommended: ES256
import { SignJWT, jwtVerify } from 'jose';
const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'ES256');
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'ES256');
// Sign
const token = await new SignJWT({ sub: userId, scope: 'read write' })
.setProtectedHeader({ alg: 'ES256', typ: 'JWT', kid: keyId })
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m')
.setIssuedAt()
.setJti(crypto.randomUUID())
.sign(privateKey);
トークン構造
interface AccessTokenPayload {
// Standard claims
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string; // Audience
exp: number; // Expiration (Unix timestamp)
iat: number; // Issued at
jti: string; // JWT ID (unique identifier)
// Custom claims
scope: string; // Permissions
email?: string; // User email
roles?: string[]; // User roles
}
検証(重要)
import { jwtVerify, errors } from 'jose';
async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
try {
const { payload } = await jwtVerify(token, publicKey, {
// CRITICAL: Explicitly specify allowed algorithms
algorithms: ['ES256'],
// Validate standard claims
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
// Clock tolerance for sync issues
clockTolerance: 30,
});
// Additional validation
if (!payload.scope?.includes('read')) {
throw new Error('Insufficient scope');
}
return payload as AccessTokenPayload;
} catch (err) {
if (err instanceof errors.JWTExpired) {
throw new AuthError('Token expired', 'TOKEN_EXPIRED');
}
if (err instanceof errors.JWTClaimValidationFailed) {
throw new AuthError('Invalid token claims', 'INVALID_CLAIMS');
}
throw new AuthError('Invalid token', 'INVALID_TOKEN');
}
}
トークンの保存
Web アプリケーション
// Set token in HttpOnly cookie (server-side)
function setAuthCookie(res: Response, token: string) {
res.cookie('access_token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/api', // Only sent to API routes
});
}
// Refresh token (longer-lived)
function setRefreshCookie(res: Response, token: string) {
res.cookie('refresh_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only for refresh endpoint
});
}
シングルページアプリケーション(SPA)
// Store in memory (NOT localStorage/sessionStorage)
class TokenManager {
private accessToken: string | null = null;
setToken(token: string) {
this.accessToken = token;
}
getToken(): string | null {
return this.accessToken;
}
clearToken() {
this.accessToken = null;
}
}
// Use with Refresh Token Rotation
// Refresh token in HttpOnly cookie
// Access token in memory
ストレージの比較
| ストレージ | XSS に安全 | CSRF に安全 | 永続性 |
|---|---|---|---|
| HttpOnly Cookie | はい | SameSite が必要 | はい |
| メモリ | はい | はい | いいえ(再読み込み時に失われます) |
| localStorage | いいえ | はい | はい |
| sessionStorage | いいえ | はい | タブ内のみ |
リフレッシュトークンのローテーション
フロー
1. Client sends refresh_token
2. Server validates refresh_token
3. Server generates NEW access_token + NEW refresh_token
4. Server INVALIDATES old refresh_token
5. Server returns new tokens
6. Client stores new tokens
実装
async function refreshTokens(refreshToken: string) {
// Find token in database
const stored = await db.refreshToken.findUnique({
where: { token: hashToken(refreshToken) },
include: { user: true },
});
if (!stored) {
throw new AuthError('Invalid refresh token', 'INVALID_TOKEN');
}
// Check if already used (reuse detection)
if (stored.usedAt) {
// Potential token theft - revoke ALL user tokens
await db.refreshToken.deleteMany({
where: { userId: stored.userId },
});
// Alert security team
await alertSecurityTeam({
event: 'REFRESH_TOKEN_REUSE',
userId: stored.userId,
tokenId: stored.id,
});
throw new AuthError('Token reuse detected', 'TOKEN_REUSE');
}
// Check expiration
if (stored.expiresAt < new Date()) {
throw new AuthError('Refresh token expired', 'TOKEN_EXPIRED');
}
// Mark as used (but keep for reuse detection)
await db.refreshToken.update({
where: { id: stored.id },
data: { usedAt: new Date() },
});
// Generate new tokens
const newAccessToken = await generateAccessToken(stored.user);
const newRefreshToken = await generateRefreshToken(stored.user);
// Store new refresh token
await db.refreshToken.create({
data: {
token: hashToken(newRefreshToken),
userId: stored.userId,
expiresAt: addDays(new Date(), 7),
previousTokenId: stored.id, // Chain for audit
},
});
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
攻撃の防止
アルゴリズムの混同
// WRONG: Trusts header algorithm
jwt.verify(token, key); // Uses alg from header
// CORRECT: Explicit algorithm
jwt.verify(token, key, { algorithms: ['ES256'] });
CSRF 対策
// Use SameSite cookies
res.cookie('session', token, {
sameSite: 'strict', // or 'lax' for cross-site links
});
// Or double-submit cookie pattern
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', csrfToken, { httpOnly: false });
// Client sends csrf token in header
XSS 対策
// Content Security Policy
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
].join('; '));
// Use HttpOnly cookies for tokens
// Never store tokens in localStorage
トークンバインディング(DPoP)
// Demonstration of Proof of Possession
// Bind token to client's key pair
const dpopProof = await new SignJWT({
htm: 'POST',
htu: 'https://api.example.com/resource',
ath: await hashAccessToken(accessToken), // Access token hash
})
.setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicKey })
.setJti(crypto.randomUUID())
.setIssuedAt()
.sign(privateKey);
// Send with request
fetch('https://api.example.com/resource', {
headers: {
Authorization: `DPoP ${accessToken}`,
DPoP: dpopProof,
},
});
トークンの失効
// Revoke all user tokens (e.g., password change, logout all)
async function revokeAllUserTokens(userId: string) {
await db.refreshToken.deleteMany({
where: { userId },
});
// If using token blacklist for access tokens
await redis.sadd(`revoked:${userId}`, Date.now());
await redis.expire(`revoked:${userId}`, 15 * 60); // 15 min (access token lifetime)
}
// Check blacklist during verification
async function isTokenRevoked(userId: string, iat: number): Promise<boolean> {
const revokedAt = await redis.get(`revoked:${userId}`);
return revokedAt && parseInt(revokedAt) > iat * 1000;
}
チェックリスト
## OAuth 2.1
- [ ] Using Authorization Code flow
- [ ] PKCE enabled for all clients
- [ ] No implicit or password grants
- [ ] Redirect URI exact matching
## JWT
- [ ] Using ES256 or EdDSA algorithm
- [ ] Explicit algorithm verification
- [ ] Short expiration (≤15 min)
- [ ] Unique jti for each token
- [ ] Issuer and audience validation
## Tokens
- [ ] HttpOnly cookies for web apps
- [ ] Refresh token rotation enabled
- [ ] Reuse detection implemented
- [ ] Token revocation mechanism
## Security
- [ ] HTTPS everywhere
- [ ] SameSite cookies
- [ ] CSP headers configured
- [ ] Rate limiting on auth endpoints
- [ ] Brute force protection
関連項目
- reference/oauth2.1.md — OAuth 2.1 の詳細
- reference/jwt.md — JWT のパターン
- reference/attacks.md — 攻撃の防止
- templates/typescript/auth.service.ts — TypeScript 認証サービスのスターター
コメント
Markdown 対応