☁️

Cloudflare Workers で Google OAuth を 使ったJWT認証システムを構築する

に公開

概要

本記事では、Cloudflare Workers(Hono)とNext.jsを使用して、Google OAuth 2.0とJWT(JSON Web Token)を組み合わせた認証システムの構築方法を解説します。
2025年10月現在では@hono/auth-jsを使った実装では、Cloudflare D1などの独自データベース上のホワイトリストをもとにする仕様ではうまくいかなかったのでこの方法を取っています。
記事の分量の都合上、UI(Next.js などフロントエンド)の説明は含みません。ご了承ください。

認証フロー(PKCE + JWKS検証対応)

目的

  • Google OAuth 2.0(Authorization Code + PKCE)でアクセストークン/IDトークンを取得
  • IDトークンをGoogleのJWKSで検証(署名/iss/aud/exp/nonce)
  • userinfoからプロフィール取得し、email_verified を確認
  • D1のホワイトリストと突き合わせた上で、自前のJWTを発行
  • JWTをHttpOnly+SameSite=Lax(+Secure) Cookieでクライアントへ返却

auth.ts のAPI

型定義

worker/src/auth.ts
export interface AuthUser {
  id: string;
  email: string;
  name: string;
  image?: string;
  emailVerified?: boolean;
}

export interface CustomJWTPayload {
  sub: string;
  email: string;
  name: string;
  picture?: string;
  iat: number;
  exp: number;
}

乱数・PKCE・nonce

worker/src/auth.ts
export function generateState(): string
export function generateNonce(): string
export function generateCodeVerifier(): string
export async function generateCodeChallenge(verifier: string): Promise<string>
  • generateState(): 32バイトのランダム値を16進文字列で返す(CSRF防止用)
  • generateNonce(): 32バイトのランダム値を16進文字列で返す(IDトークン検証用)
  • generateCodeVerifier(): 32バイトのランダム値をBase64URLエンコードで返す(PKCE用)
  • generateCodeChallenge(): code_verifierをSHA256でハッシュ化し、Base64URLエンコードで返す

認可URLの生成

worker/src/auth.ts
export function createGoogleAuthUrl(
  clientId: string,
  redirectUri: string,
  state: string,
  codeChallenge: string,
  nonce: string,
): string

パラメータ:

  • client_id: Google OAuth クライアントID
  • redirect_uri: コールバック先のURI
  • response_type: code(固定)
  • scope: openid email profile(固定)
  • state: CSRF防止用のstateパラメータ
  • access_type: offline(固定)
  • prompt: consent(固定)
  • code_challenge: PKCE用のcode_challenge(S256)
  • code_challenge_method: S256(固定)
  • nonce: IDトークン検証用のnonce

トークン交換(code → tokens)

worker/src/auth.ts
export async function exchangeCodeForToken(
  code: string,
  clientId: string,
  clientSecret: string,
  redirectUri: string,
  codeVerifier: string,
): Promise<{ accessToken: string; idToken?: string } | null>

Googleのhttps://oauth2.googleapis.com/tokenエンドポイントにPOSTリクエストを送信:

  • grant_type: authorization_code
  • code: 認可コード
  • client_id: Google OAuth クライアントID
  • client_secret: Google OAuth クライアントシークレット
  • redirect_uri: 認可時に使用したredirect_uri(必須)
  • code_verifier: PKCE用のcode_verifier

成功時はaccess_tokenid_tokenを返す。

Google IDトークン検証(JWKS)

worker/src/auth.ts
export async function verifyGoogleIdToken(
  idToken: string,
  clientId: string,
  expectedNonce?: string,
): Promise<any | null>

検証手順:

  1. IDトークンを3つの部分(header.payload.signature)に分割
  2. headerからalgkidを取得(algRS256である必要がある)
  3. GoogleのJWKSエンドポイント(https://www.googleapis.com/oauth2/v3/certs)から公開鍵を取得(60秒キャッシュ)
  4. kidに一致する公開鍵でRS256署名を検証
  5. payloadのisshttps://accounts.google.comまたはaccounts.google.comであることを確認
  6. payloadのaudclientIdと一致することを確認
  7. payloadのexpが現在時刻より後であることを確認
  8. expectedNonceが指定されている場合、payloadのnonceと一致することを確認

ユーザープロファイル取得(access_token)

worker/src/auth.ts
export async function getGoogleUserInfo(accessToken: string): Promise<AuthUser | null>

Googleのhttps://www.googleapis.com/oauth2/v2/userinfoエンドポイントにアクセストークンでアクセスし、ユーザー情報を取得:

  • id: GoogleアカウントのID
  • email: メールアドレス
  • name: 表示名
  • picture: プロフィール画像URL
  • emailVerified: verified_emailまたはemail_verifiedフィールドから取得

自前JWT(アプリ用)

worker/src/auth.ts
export async function generateJWT(user: AuthUser, secret: string): Promise<string>
export async function verifyJWT(token: string, secret: string): Promise<any | null>
  • generateJWT(): HMAC-SHA256で署名されたJWTを生成
    • sub: D1上の実ユーザーID
    • email: ユーザーのメールアドレス
    • name: ユーザーの表示名
    • picture: プロフィール画像URL
    • iat: 発行時刻(Unix timestamp)
    • exp: 有効期限(発行時刻から1週間後)
  • verifyJWT(): JWTの署名と有効期限を検証し、payloadを返す

Workerからの使い方(ルート実装の要点)

1) 認可開始(POST /auth/signin/google)

  • state/nonce/code_verifier を生成し、すべて HttpOnly Cookie へ保存(有効期限は短め、ここでは例として10分)
  • createGoogleAuthUrl()code_challenge(S256)nonce を付与したURLを生成
  • そのURLをJSONでクライアントへ返し、クライアントはそのURLへ遷移

実装例:

worker/src/index.ts
app.post('/auth/signin/google', async (c) => {
  try {
    const state = generateState();
    const nonce = generateNonce();
    const codeVerifier = generateCodeVerifier();
    const codeChallenge = await generateCodeChallenge(codeVerifier);
    const redirectUri = `${c.env.AUTH_URL}/auth/callback/google`;

    // Cookie保存(10分間有効)
    setCookie(c, 'oauth_state', state, {
      httpOnly: true,
      secure: c.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 600,
      path: '/',
    });
    setCookie(c, 'oauth_nonce', nonce, {
      httpOnly: true,
      secure: c.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 600,
      path: '/',
    });
    setCookie(c, 'pkce_verifier', codeVerifier, {
      httpOnly: true,
      secure: c.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 600,
      path: '/',
    });

    const authUrl = createGoogleAuthUrl(
      c.env.GOOGLE_CLIENT_ID,
      redirectUri,
      state,
      codeChallenge,
      nonce
    );

    return c.json({ authUrl });
  } catch (error) {
    console.error('Auth signin error:', error);
    return c.json({ error: 'Authentication failed' }, 500);
  }
});

2) コールバック(GET /auth/callback/google)

  • Cookieから state/nonce/code_verifier を取得し、stateを照合
  • exchangeCodeForToken()code_verifier/redirect_uri を渡してトークン交換
  • verifyGoogleIdToken()id_token をJWKS検証(署名/iss/aud/exp/nonce)
  • getGoogleUserInfo()email_verified を確認
  • D1でホワイトリスト判定後、generateJWT() で自前JWT生成 → HttpOnly Cookie で返却

実装例:

worker/src/index.ts
app.get('/auth/callback/google', async (c) => {
  try {
    const code = c.req.query('code');
    const state = c.req.query('state');
    const storedState = getCookie(c, 'oauth_state');
    const storedNonce = getCookie(c, 'oauth_nonce');
    const storedVerifier = getCookie(c, 'pkce_verifier');

    // state検証
    if (!code || !state || !storedState || state !== storedState) {
      return c.json({ error: 'Invalid state parameter' }, 400);
    }

    // Cookie削除
    deleteCookie(c, 'oauth_state');
    deleteCookie(c, 'oauth_nonce');
    deleteCookie(c, 'pkce_verifier');

    // トークン交換
    const redirectUri = `${c.env.AUTH_URL}/auth/callback/google`;
    const tokenData = await exchangeCodeForToken(
      code,
      c.env.GOOGLE_CLIENT_ID,
      c.env.GOOGLE_CLIENT_SECRET,
      redirectUri,
      storedVerifier || ''
    );

    if (!tokenData) {
      return c.json({ error: 'Token exchange failed' }, 400);
    }

    // IDトークン検証(JWKS)
    if (tokenData.idToken) {
      const idPayload = await verifyGoogleIdToken(
        tokenData.idToken,
        c.env.GOOGLE_CLIENT_ID,
        storedNonce || undefined
      );
      if (!idPayload) {
        return c.json({ error: 'Invalid id_token' }, 401);
      }
    }

    // ユーザー情報取得
    const googleUser = await getGoogleUserInfo(tokenData.accessToken);
    if (!googleUser) {
      return c.json({ error: 'Failed to get user info' }, 400);
    }
    if (googleUser.emailVerified === false) {
      return c.json({ error: 'Email not verified' }, 403);
    }

    // ホワイトリスト判定
    const dbUser = await c.env.DB.prepare(
      'SELECT * FROM users WHERE email = ?'
    ).bind(googleUser.email).first() as any;

    if (!dbUser) {
      return c.json({ error: 'Access denied - user not in whitelist' }, 403);
    }

    // JWT生成
    const jwt = await generateJWT({
      id: dbUser.id,
      email: googleUser.email,
      name: googleUser.name,
      image: googleUser.image,
    }, c.env.AUTH_SECRET);

    // HttpOnly + Secure CookieにJWTを設定(1週間有効)
    setCookie(c, 'auth_token', jwt, {
      httpOnly: true,
      secure: c.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 7 * 24 * 60 * 60, // 1週間
      path: '/',
    });

    // フロントエンドにリダイレクト
    return c.redirect(`${c.env.FRONTEND_URL}/auth/callback`);
  } catch (error) {
    console.error('Auth callback error:', error);
    return c.json({ error: 'Authentication callback failed' }, 500);
  }
});

3) セッション取得(GET /auth/session)

  • Cookieから自前JWTを読み取り verifyJWT() で検証
  • sub(D1のユーザーID)でユーザーを読み出し、必要情報を返す

実装例:

worker/src/index.ts
app.get('/auth/session', async (c) => {
  try {
    const token = getCookie(c, 'auth_token');
    
    if (!token) {
      return c.json({ user: null });
    }

    const payload = await verifyJWT(token, c.env.AUTH_SECRET);
    if (!payload) {
      return c.json({ user: null });
    }

    const fullUser = await c.env.DB.prepare('SELECT * FROM users WHERE id = ?')
      .bind(payload.sub).first() as any;

    if (!fullUser) {
      return c.json({ user: null });
    }

    return c.json({
      user: {
        id: fullUser.id,
        student_number: fullUser.student_number,
        name: fullUser.name,
        nickname: fullUser.nickname,
        email: fullUser.email,
        instruments: JSON.parse(fullUser.instruments || '[]'),
        grade: Number(fullUser.grade),
        role: fullUser.role,
        created_at: fullUser.created_at,
        updated_at: fullUser.updated_at,
      }
    });
  } catch (error) {
    console.error('Session error:', error);
    return c.json({ user: null });
  }
});

4) ログアウト(POST /auth/signout)

  • JWT Cookie を削除

実装例:

worker/src/index.ts
app.post('/auth/signout', async (c) => {
  try {
    deleteCookie(c, 'auth_token', {
      path: '/',
      httpOnly: true,
      secure: c.env.NODE_ENV === 'production',
      sameSite: 'lax',
    });

    return c.json({ success: true });
  } catch (error) {
    console.error('Signout error:', error);
    return c.json({ success: false, error: 'Signout failed' }, 500);
  }
});

5) 認証ミドルウェア

  • 保護されたルートでJWTを検証し、ユーザー情報をコンテキストに設定

実装例:

worker/src/middleware/auth.ts
import type { Context } from 'hono';
import { getCookie } from 'hono/cookie';
import { verifyJWT } from '../auth';

export const requireAuth = async (c: Context, next: () => Promise<void>) => {
  try {
    const token = getCookie(c, 'auth_token');
    
    if (!token) {
      return c.json({ success: false, error: 'No authentication token' }, 401);
    }

    const payload = await verifyJWT(token, c.env.AUTH_SECRET);
    if (!payload) {
      return c.json({ success: false, error: 'Invalid token' }, 401);
    }

    // ユーザー情報をDBから取得(例)
    const user = await getUserById(c.env.DB, payload.sub);
    if (!user) {
      return c.json({ success: false, error: 'User not found' }, 401);
    }

    // コンテキストにユーザー情報を設定
    c.set('user', user);
    await next();
  } catch (error) {
    console.error('Authentication error:', error);
    return c.json({ success: false, error: 'Authentication failed' }, 401);
  }
};
使用例
app.get('/protected-route', requireAuth, async (c) => {
  const user = c.get('user'); // ミドルウェアで設定されたユーザー情報
  return c.json({ message: `Hello ${user.name}` });
});

セキュリティのポイント

  • PKCE(S256)で認可コード窃取対策
  • IDトークンのJWKS検証 + iss/aud/exp/nonce チェック
  • email_verified が false のアカウントは拒否
  • JWT は1週間・HttpOnly+SameSite=Lax(+Secure) Cookieで発行
  • ホワイトリスト判定 をD1で実施

環境変数

AUTH_SECRET=your-auth-secret-here-min-32-chars-long
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
AUTH_URL=http://localhost:8787
FRONTEND_URL=http://localhost:3000
NODE_ENV=development

まとめ

  • 認可開始〜コールバック〜JWT発行までを auth.ts のAPIで統一
  • Workers単体でセキュアなOAuth 2.0 + JWTフローを実現可能
  • PKCE、JWKS検証、email_verified確認により、セキュリティを強化

将来的には@hono/auth-jsのAPIでこのような機能が提供されると期待しつつ、気合いで実装してます。
読んでいただきありがとうございました。

Discussion