DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでOAuth2 PKCEフローを設計する:認可コード・SPAセキュア認証・リフレッシュ

はじめに

SPAでimplicit flowを使うのは危険——PKCE(Proof Key for Code Exchange)で認可コードフローを安全に実装する。Claude Codeにサーバーサイドとクライアントサイドの実装を設計させる。


CLAUDE.mdにOAuth PKCE設計ルールを書く

## OAuth2 PKCE設計ルール

### PKCEフロー
- code_verifier: 43-128文字のランダム文字列
- code_challenge: SHA256(code_verifier)のBase64URL
- 認可URLにcode_challenge + code_challenge_method=S256を付与
- トークン交換時にcode_verifierを送信

### トークン管理
- access_token: 15分(短命)
- refresh_token: 30日(回転式)
- access_tokenはメモリ管理(localStorage禁止)
- refresh_tokenはhttpOnly Cookie

### セキュリティ
- state: CSRF防止(32バイトランダム)
- nonce: リプレイ攻撃防止
- PKCE: コード傍受攻撃防止
Enter fullscreen mode Exit fullscreen mode

OAuth PKCE実装の生成

OAuth2 PKCEフロー認証を設計してください。

要件:
- PKCE(code_verifier/challenge)
- Google/GitHub OAuth対応
- 回転式リフレッシュトークン
- SPA向けセキュアなトークン管理

生成ファイル: src/auth/oauth/
Enter fullscreen mode Exit fullscreen mode

生成されるOAuth PKCE実装

// src/auth/oauth/pkceClient.ts — クライアントサイド(SPA)

export class PKCEClient {
  async initiateAuth(provider: 'google' | 'github'): Promise<void> {
    const codeVerifier = this.generateCodeVerifier();
    const codeChallenge = await this.generateCodeChallenge(codeVerifier);
    const state = crypto.randomUUID();
    const nonce = crypto.randomUUID();

    sessionStorage.setItem(`pkce_verifier`, codeVerifier);
    sessionStorage.setItem(`pkce_state`, state);
    sessionStorage.setItem(`pkce_nonce`, nonce);

    const authUrl = new URL(PROVIDER_CONFIGS[provider].authEndpoint);
    authUrl.searchParams.set('client_id', PROVIDER_CONFIGS[provider].clientId);
    authUrl.searchParams.set('redirect_uri', `${window.location.origin}/auth/callback`);
    authUrl.searchParams.set('response_type', 'code');
    authUrl.searchParams.set('scope', 'openid email profile');
    authUrl.searchParams.set('state', state);
    authUrl.searchParams.set('nonce', nonce);
    authUrl.searchParams.set('code_challenge', codeChallenge);
    authUrl.searchParams.set('code_challenge_method', 'S256');

    window.location.href = authUrl.toString();
  }

  async handleCallback(searchParams: URLSearchParams): Promise<void> {
    const code = searchParams.get('code');
    const returnedState = searchParams.get('state');
    const error = searchParams.get('error');

    if (error) throw new AuthError(`OAuth error: ${error}`);
    if (!code) throw new AuthError('No authorization code');

    const savedState = sessionStorage.getItem('pkce_state');
    if (returnedState !== savedState) throw new AuthError('State mismatch: possible CSRF attack');

    const codeVerifier = sessionStorage.getItem('pkce_verifier');
    sessionStorage.removeItem('pkce_state');
    sessionStorage.removeItem('pkce_verifier');
    sessionStorage.removeItem('pkce_nonce');

    const response = await fetch('/api/auth/oauth/callback', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({ code, codeVerifier }),
    });

    const { accessToken } = await response.json();
    this.accessToken = accessToken;
  }

  private generateCodeVerifier(): string {
    const array = new Uint8Array(96);
    crypto.getRandomValues(array);
    return btoa(String.fromCharCode(...array))
      .replace(/+/g, '-').replace(///g, '_').replace(/=/g, '');
  }

  private async generateCodeChallenge(verifier: string): Promise<string> {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const digest = await crypto.subtle.digest('SHA-256', data);
    return btoa(String.fromCharCode(...new Uint8Array(digest)))
      .replace(/+/g, '-').replace(///g, '_').replace(/=/g, '');
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/auth/oauth/oauthServer.ts — サーバーサイド(トークン交換)

export async function handleOAuthCallback(
  code: string,
  codeVerifier: string,
  provider: 'google' | 'github'
): Promise<{ accessToken: string }> {
  const tokenResponse = await fetch(PROVIDER_CONFIGS[provider].tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: process.env.OAUTH_REDIRECT_URI!,
      client_id: PROVIDER_CONFIGS[provider].clientId,
      client_secret: PROVIDER_CONFIGS[provider].clientSecret,
      code_verifier: codeVerifier,
    }),
  });

  const tokens = await tokenResponse.json();
  const userInfo = await fetchUserInfo(tokens.access_token, provider);

  const user = await prisma.user.upsert({
    where: { email: userInfo.email },
    create: { email: userInfo.email, name: userInfo.name, provider, providerId: userInfo.id },
    update: { name: userInfo.name },
  });

  const accessToken = await generateJWT({ sub: user.id, role: user.role }, { expiresIn: '15m' });
  const refreshToken = generateSecureToken(48);

  await prisma.refreshToken.create({
    data: {
      token: hashToken(refreshToken),
      userId: user.id,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  return { accessToken, refreshToken };
}

export async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenHash = hashToken(oldRefreshToken);
  const stored = await prisma.refreshToken.findFirst({
    where: { token: tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
  });

  if (!stored) throw new UnauthorizedError('Invalid or expired refresh token');

  await prisma.refreshToken.update({
    where: { id: stored.id },
    data: { revokedAt: new Date() },
  });

  const newAccessToken = await generateJWT({ sub: stored.userId }, { expiresIn: '15m' });
  const newRefreshToken = generateSecureToken(48);

  await prisma.refreshToken.create({
    data: {
      token: hashToken(newRefreshToken),
      userId: stored.userId,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      parentTokenId: stored.id,
    },
  });

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
Enter fullscreen mode Exit fullscreen mode

まとめ

Claude CodeでOAuth2 PKCEフローを設計する:

  1. CLAUDE.md にPKCEフロー・access_token 15分・refresh_token 30日回転式・httpOnly Cookieを明記
  2. code_verifier をセッションストレージに一時保存し、コールバック後に削除
  3. state検証 でCSRF攻撃を防ぎ、code_verifier検証 でコード傍受攻撃を防ぐ
  4. 回転式refresh_token で使用後に新しいトークンを発行(リプレイ攻撃防止)

OAuth設計のレビューは **Security Pack(¥1,480)* の /security-check で確認できます。*

prompt-works.jp

Top comments (0)