zudo-cloudflare-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Pages Functions での認証

Cloudflare Pages Functions を使った認証パターン

概要

Pages Functions は外部認証プロバイダー(Auth0 など)との連携やカスタムトークン検証を実装することで認証を処理できます。

ここで最も重要なのは どの種類のトークンを検証するのか という点です。それによって必要な検証方法が決まります。コードをコピーする前に、まず下の 2 つの仕事、2 つの道具 を読んでください。

2 つの仕事、2 つの道具

Worker / Pages Function での認証は、たいてい次の 2 つのまったく異なる仕事のどちらかです。アルゴリズムも検証の仕組みも違うので、混同しないでください。

仕事トークンの発行者アルゴリズム検証方法
サードパーティ IdP のトークンを検証する(Auth0、Google など)自分が管理していない外部の ID プロバイダーRS256(非対称鍵)プロバイダーの公開鍵(JWKS)を取得し、Web Crypto でローカルに署名を検証
自前のセッション トークンを発行・検証する自分のバックエンドHS256(対称鍵)wrangler secret に保管した共有シークレットで署名・検証

両者が異なる理由はこうです。サードパーティ IdP の場合、署名鍵を持っているのはプロバイダーだけで、自分は決して所有しません。プロバイダーは対応する 公開 鍵セット(JWKS)を配布し、自分はそれに対して検証します。一方、自前のセッショントークンは両端を自分で管理しているので、ひとつの共有シークレット(HS256)のほうがシンプルかつ高速です。

このページの残りでは、まずサードパーティのケース(RS256 + JWKS)を詳しく扱い、続いて自前のケース(HS256)を手短に示します。

サードパーティ IdP のトークンを検証する(RS256 + JWKS)

Wrangler 設定

[vars]
AUTH0_DOMAIN = "your-tenant.us.auth0.com"
AUTH0_CLIENT_ID = "your-client-id"
AUTH0_AUDIENCE = "https://your-api.example.com"

シークレットと変数の違い

AUTH0_CLIENT_ID は公開情報なので [vars] に記述して問題ありません。しかし AUTH0_CLIENT_SECRETwrangler secret put AUTH0_CLIENT_SECRET で設定する必要があります -- wrangler.toml には絶対に書かないでください。

ローカルで検証する -- リクエストごとに /userinfo を呼ばない

ありがちですが間違ったやり方は、リクエストごとに IdP の /userinfo エンドポイントへベアラートークンを付けて検証することです。これは各リクエストを Auth0 へのネットワークラウンドトリップ にしてしまい、レイテンシが増え、アプリの可用性が IdP に依存し、IdP のレート制限を消費します。

トークンは 署名済みの JWT です。Web Crypto API だけを使ってオフラインで検証できます -- Node の jsonwebtoken も Node の crypto も、リクエストごとの外部通信も不要です。IdP の公開鍵を 一度だけ 取得してキャッシュし、署名はローカルで検証します。

JWT は base64url でエンコードされた 3 つの部分 header.payload.signature から成ります。検証とは次のことです:

  1. ヘッダーから kid(鍵 ID)を読み取る。

  2. IdP の JWKS から一致する公開鍵を探す。

  3. その鍵で header.payload に対する署名を検証する。

  4. issaudexp のクレームを検証する。

モジュールレベルのキャッシュ付き JWKS 取得

キャッシュをモジュールスコープで定義し、同じアイソレート内のリクエスト間で生存する ようにします。再取得が走るのは、1 時間の TTL が切れたとき、またはキャッシュに無い kid をトークンが参照したとき(鍵のローテーション)だけです。

負荷に耐え、複数の発行者に対応するために、次の 4 点を組み込んでいます。

  • 発行者ごとにキー分割。 キャッシュ・進行中のフェッチ・強制再取得のクールダウンはすべて発行者のドメイン(つまり JWKS URL)をキーにしているので、1 つのアイソレートが複数の IdP を状態衝突なく扱えます。

  • 進行中フェッチの重複排除。 複数のリクエストが同時にキャッシュ切れ・期限切れに当たった場合、それぞれが別々に IdP へリクエストを飛ばすのではなく、進行中の 1 つのフェッチを共有します。

  • 強制再取得のクールダウン。 kid が見つからないと鍵ローテーションに対応するため再取得を強制しますが、制限がなければ、でたらめな kid を持つトークンの連打がリクエストのたびに再取得を強制してしまいます。このクールダウンは直近の成功ではなく直近の試行を基準にゲートしているので、再取得が失敗し続けても時計はリセットされず、攻撃者が再取得を強制し続けることはできません。進行中フェッチが失敗した場合は即座にクリアされ、キャッシュされたままにはならないので、IdP の一時的な障害が永久に失敗し続ける状態にはなりません。

  • クリーンアップ用チェーン自身の rejection を消費する。 .finally()inFlight とは別の、もう 1 つの Promise を返し、フェッチが失敗するたびにその Promise も reject します -- ここではそれを await も catch もしていません。放置すると、fetchJwks の呼び出し元が元の失敗を正しく catch していたとしても、Worker の中で未処理の rejection になってしまい、正しく処理されたはずの IdP 障害が無関係なクラッシュに化けます。.finally() チェーンに no-op の .catch() を付けることで、呼び出し元が受け取るものを変えずにこれを消費できます。以下で unwrap されたまま返される inFlight 自体は、引き続き本物の rejection を運びます。

// functions/auth/jwks.ts
interface JwksKey {
  kty: string;
  kid: string;
  use: string;
  alg: string;
  n: string;
  e: string;
}

interface JwksResponse {
  keys: JwksKey[];
}

interface JwksCacheEntry {
  keys: JwksKey[];
  fetchedAt: number;
}

// Keyed per issuer (JWKS URL) so one isolate can safely cache multiple IdPs.
// All three maps survive across requests in the same isolate, not across isolates.
const jwksCache = new Map<string, JwksCacheEntry>();
const inFlightFetches = new Map<string, Promise<JwksKey[]>>();
const lastForceRefreshAttempt = new Map<string, number>();

const JWKS_CACHE_TTL = 3600_000; // 1 hour
const FORCE_REFRESH_COOLDOWN = 10_000; // 10s: caps refetch rate on a kid miss

export async function fetchJwks(
  domain: string,
  forceRefresh = false,
): Promise<JwksKey[]> {
  const now = Date.now();
  const cached = jwksCache.get(domain);

  if (!forceRefresh && cached && now - cached.fetchedAt < JWKS_CACHE_TTL) {
    return cached.keys;
  }

  // Dedup concurrent callers for the same issuer: join the fetch already
  // in flight instead of firing another request at the IdP. Checked before
  // the cooldown below so two forced refetches arriving together still
  // share one request instead of the second being cooldown-blocked.
  const existing = inFlightFetches.get(domain);
  if (existing) {
    return existing;
  }

  if (forceRefresh) {
    // Gate on the last fetch ATTEMPT, not the last success: a burst of
    // tokens with bogus `kid`s must not force a refetch on every request,
    // even while every prior attempt has been failing.
    const lastAttempt = lastForceRefreshAttempt.get(domain) ?? 0;
    if (now - lastAttempt < FORCE_REFRESH_COOLDOWN) {
      return cached?.keys ?? [];
    }
    lastForceRefreshAttempt.set(domain, now);
  }

  const inFlight = (async () => {
    const url = `https://${domain}/.well-known/jwks.json`;
    const res = await fetch(url);
    if (!res.ok) {
      throw new Error(`Failed to fetch JWKS: ${res.status}`);
    }
    const data = (await res.json()) as JwksResponse;
    jwksCache.set(domain, { keys: data.keys, fetchedAt: Date.now() });
    return data.keys;
  })();
  inFlightFetches.set(domain, inFlight);
  // Clear on success AND failure -- a failed fetch must not be cached
  // forever, or every future call would replay the same rejection.
  // .finally() returns its own promise, which also rejects when `inFlight`
  // does; the no-op .catch() consumes that second rejection so it never
  // surfaces as unhandled. `inFlight` below is still the original, unwrapped
  // promise, so the caller's own catch/try still sees the real failure.
  inFlight.finally(() => inFlightFetches.delete(domain)).catch(() => {});

  return inFlight;
}

Tip

1 時間の TTL が表すのは 1 つのアイソレート内 のキャッシュの話であり、IdP が実際に受け取るフェッチ頻度そのものではありません。アイソレートは再デプロイ・スケールツーゼロ・Cloudflare 側の配置判断によって入れ替わり、新しいアイソレートは必ずコールドスタートします。アクセスの多いルートでは多数のアイソレートが並行して動き、それぞれが自前のキャッシュを持ちます。fetchJwks が実際にネットワークリクエストを発行するたびにログを出す(あるいはメトリクスをインクリメントする)ようにしておくと、TTL から「1 時間に 1 回のはず」と決めつけるのではなく、IdP への実際のフェッチ頻度を観測できます。

Web Crypto による RS256 検証

base64url を手作業でデコードし(JWT は URL セーフな文字種を使い、パディングがありません)、JWK を RSASSA-PKCS1-v1_5 鍵としてインポートして検証します。kid がキャッシュに見つからない場合は、鍵ローテーションに対応するため JWKS を一度だけ再取得します(上記の再取得クールダウンの対象になります)。

// functions/auth/verify.ts
import { fetchJwks } from "./jwks";

export interface Auth0Claims {
  sub: string;
  email?: string;
  name?: string;
  exp: number;
  iat: number;
  iss: string;
  aud: string | string[];
}

function base64urlDecode(str: string): Uint8Array {
  const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
  const paddingNeeded = (4 - (base64.length % 4)) % 4;
  const binary = atob(base64 + "=".repeat(paddingNeeded));
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function decodeJwtPart(part: string): Record<string, unknown> {
  return JSON.parse(new TextDecoder().decode(base64urlDecode(part)));
}

export async function verifyAuth0Token(
  token: string,
  domain: string,
  audience: string,
): Promise<Auth0Claims> {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("Invalid token format");
  const [headerB64, payloadB64, sigB64] = parts;

  // 1. Read kid + alg from the header.
  const header = decodeJwtPart(headerB64) as { alg: string; kid: string };
  if (header.alg !== "RS256") throw new Error("Unsupported algorithm");
  if (!header.kid) throw new Error("Missing kid in token header");

  // 2. Find the matching key; refetch JWKS once on a kid miss (rotation).
  let keys = await fetchJwks(domain);
  let jwk = keys.find((k) => k.kid === header.kid);
  if (!jwk) {
    keys = await fetchJwks(domain, true);
    jwk = keys.find((k) => k.kid === header.kid);
  }
  if (!jwk) throw new Error("No matching key found in JWKS");

  // 3. Import the RSA public key.
  const cryptoKey = await crypto.subtle.importKey(
    "jwk",
    { kty: jwk.kty, n: jwk.n, e: jwk.e },
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["verify"],
  );

  // 4. Verify the signature over `header.payload`.
  const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
  const signature = base64urlDecode(sigB64);
  const valid = await crypto.subtle.verify(
    "RSASSA-PKCS1-v1_5",
    cryptoKey,
    signature,
    signingInput,
  );
  if (!valid) throw new Error("Invalid signature");

  // 5. Validate claims.
  const payload = decodeJwtPart(payloadB64) as unknown as Auth0Claims;

  const expectedIss = `https://${domain}/`;
  if (payload.iss !== expectedIss) {
    throw new Error(`Invalid issuer: expected ${expectedIss}`);
  }

  const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
  if (!aud.includes(audience)) {
    throw new Error("Token audience does not match");
  }

  const now = Math.floor(Date.now() / 1000);
  const CLOCK_SKEW_SECONDS = 30; // tolerate small clock drift with the IdP

  // `exp` must be present and a finite number: a missing or NaN exp would
  // silently skip this check, making the token effectively immortal.
  if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
    throw new Error("Token missing a valid exp claim");
  }
  if (payload.exp < now - CLOCK_SKEW_SECONDS) {
    throw new Error("Token expired");
  }

  return payload;
}

このサンプルは nbfiat を検証していません。Auth0 のアクセストークンは nbf を設定せず、iat は参考情報にすぎないためです。利用する IdP が nbf を設定する場合は、(同じクロックスキュー許容値を使って)nbf より前に使われたトークンを拒否するチェックを追加してください。

認証ミドルウェア関数

ミドルウェアはトークンを ローカルで 検証するようになり、リクエストごとに IdP へ問い合わせることはありません。唯一のネットワーク通信は JWKS の取得で、これはアイソレートごとに最大でも 1 時間に 1 回、加えて kid が見つからないときの強制再取得が(上記のクールダウンでレート制限された上で)たまに発生します。

// functions/auth/_middleware.ts
import { verifyAuth0Token } from "./verify";

interface Env {
  AUTH0_DOMAIN: string;
  AUTH0_CLIENT_ID: string;
  AUTH0_AUDIENCE: string;
}

export const onRequest: PagesFunction<Env> = async (context) => {
  const authHeader = context.request.headers.get("Authorization");

  if (!authHeader?.startsWith("Bearer ")) {
    return new Response("Unauthorized", { status: 401 });
  }

  const token = authHeader.slice(7);

  try {
    const claims = await verifyAuth0Token(
      token,
      context.env.AUTH0_DOMAIN,
      context.env.AUTH0_AUDIENCE,
    );
    // Pass verified claims to downstream functions.
    context.data.user = claims;
    return context.next();
  } catch {
    return new Response("Unauthorized", { status: 401 });
  }
};

ファイルベースのミドルウェアルーティング

Pages Functions は実際の関数の前に実行される _middleware.ts ファイルをサポートしています:

functions/
├── auth/
│   ├── _middleware.ts    # Auth check for all /auth/* routes
│   └── callback.ts
├── api/
│   ├── _middleware.ts    # Auth check for all /api/* routes
│   └── data.ts
└── public/
    └── health.ts         # No auth needed

自前のセッショントークンを発行・検証する(HS256 + jose

トークンが 自前のもの -- ログイン後にバックエンドが発行するセッショントークン -- の場合、JWKS は不要です。ひとつの共有シークレットを持ち、それを署名と検証の両方に使います(HS256)。jose ライブラリは Workers ランタイムで動作し、これを簡潔に書けます。

シークレットは wrangler.toml に書かず、wrangler secret put SESSION_SECRET で設定してください。

// functions/lib/session.ts
import { SignJWT, jwtVerify } from "jose";

export interface SessionPayload {
  sub: string;
  type: "access" | "refresh";
  [key: string]: unknown;
}

export async function createToken(
  payload: SessionPayload,
  secret: string,
  expiresIn: string,
): Promise<string> {
  const secretKey = new TextEncoder().encode(secret);
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(expiresIn)
    .sign(secretKey);
}

export async function verifyToken(
  token: string,
  secret: string,
  expectedType: "access" | "refresh",
): Promise<SessionPayload> {
  const secretKey = new TextEncoder().encode(secret);
  const { payload } = await jwtVerify(token, secretKey);
  const result = payload as unknown as SessionPayload;
  if (result.type !== expectedType) {
    throw new Error(`Expected token type "${expectedType}" but got "${result.type}"`);
  }
  return result;
}

jwtVerify は署名 に加えて exp クレームも検証してくれるので、自前トークンの検証も完全にローカルで完結します -- ネットワーク通信はまったく発生しません。

CORS ヘッダー

異なるドメインから呼び出される API 関数の場合:

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export const onRequestOptions: PagesFunction = async () => {
  return new Response(null, { headers: corsHeaders });
};

export const onRequestGet: PagesFunction<Env> = async (context) => {
  // ... your logic
  return new Response(JSON.stringify(data), {
    headers: {
      "Content-Type": "application/json",
      ...corsHeaders,
    },
  });
};

Revision History

作成更新