OAuth 2.0 / OIDC完全実装ガイド〜Authorization Code Flow・PKCE・Google/GitHub連携【2026年版】〜

「OAuth 2.0 と OpenID Connect の違いがあいまい」「Authorization Code Flow と PKCE は結局どう書くのか」「Google ログイン・GitHub ログインを Node.js でゼロから実装したい」「JWT を返してきた ID Token は信用していいのか」「Implicit と Password はもう使ってはいけないのか」。OAuth 2.0 / OIDC は仕様書(RFC 6749, RFC 8252, OpenID Connect Core 1.0)が膨大で、現場のコードに落とすところで多くの人がつまずきます。本記事は Node.js 22 + Express 5 + TypeScript 5 + jose 5 を前提に、40 個超のコピペで動くコードで「Authorization Code Flow + PKCE」「Google / GitHub / X(Twitter)/ Apple 連携」「ID Token 検証」「Refresh / Introspection / Revocation」まで、迷いどころをすべて潰します。Express 5REST API 設計と合わせて読めば、認証認可レイヤーは完成します。

この記事を最後まで読むと身につくこと

  • OAuth 2.0 と OpenID Connect の 本質的な違い(認可 vs 認証)
  • 4 種のフローのうち 2026 年に使ってよいもの(Auth Code + PKCE / Client Credentials)
  • Express 5 + TypeScript で書く Google / GitHub / X / Apple ログインのフルコード
  • PKCE(code_verifier / code_challenge)、state、nonce の 正しい実装と検証
  • ID Token の JWKs 取得・kid・alg 検証(jose ライブラリ)
  • Refresh Token・Token Introspection(RFC 7662)・Token Revocation(RFC 7009)
  • Auth.js(NextAuth v5)・iron-session・Lucia Auth・Clerk / Auth0 / WorkOS の使い分け
  • OWASP OAuth ベストプラクティス・リダイレクト URI 設計・PKCE が必須化された背景
  1. 1. OAuth 2.0 と OpenID Connect:5 分で正しく理解する
    1. 1.1 OAuth 2.0 は「認可」、OpenID Connect は「認証」
    2. 1.2 4 つの登場人物(ロール)
    3. 1.3 フロー全体像(Authorization Code Flow + PKCE)
    4. 1.4 環境準備(本記事のスタック)
    5. 1.5 package.json の scripts
    6. 1.6 .env(本番ではシークレットマネージャへ)
  2. 2. 4 つのフローと「2026 年に使ってよいもの」
    1. 2.1 4 種類のフロー一覧
    2. 2.2 OAuth 2.1 ドラフトの主な変更
    3. 2.3 なぜ Implicit Flow は廃止されたのか
  3. 3. Authorization Code Flow + PKCE をゼロから実装
    1. 3.1 PKCE(Proof Key for Code Exchange)とは
    2. 3.2 code_verifier / code_challenge の生成(TypeScript)
    3. 3.3 state と nonce の役割
    4. 3.4 セッション設定(express-session)
    5. 3.5 Express サーバー初期化
  4. 4. Google OAuth(OIDC)を Express で実装する
    1. 4.1 Google Cloud Console での設定
    2. 4.2 Google の OIDC エンドポイント
    3. 4.3 /auth/google/login(認可リクエスト)
    4. 4.4 /auth/google/callback(トークン交換)
    5. 4.5 UserInfo エンドポイントを呼ぶ
  5. 5. ID Token(JWT)を jose で安全に検証する
    1. 5.1 なぜ ID Token の検証が必要か
    2. 5.2 JWKs を取得して鍵を回す(jose)
    3. 5.3 alg 混同攻撃(none / HS256 すり替え)を防ぐ
    4. 5.4 ID Token の主要クレーム
  6. 6. GitHub OAuth を実装する(OAuth 2.0 のみ・ID Token なし)
    1. 6.1 GitHub と Google の違い
    2. 6.2 GitHub Developer Settings での設定
    3. 6.3 /auth/github/login
    4. 6.4 /auth/github/callback
  7. 7. X(Twitter)OAuth 2.0 と Apple Sign-In
    1. 7.1 X(旧 Twitter)OAuth 2.0 with PKCE
    2. 7.2 X のトークン交換(Basic 認証ヘッダ)
    3. 7.3 Apple Sign-In(Sign in with Apple)
    4. 7.4 Apple OIDC エンドポイント
  8. 8. Refresh Token・Introspection・Revocation
    1. 8.1 Refresh Token とは
    2. 8.2 Refresh Token で access_token を更新
    3. 8.3 Token を自動更新する middleware
    4. 8.4 Token Introspection(RFC 7662)
    5. 8.5 Token Revocation(RFC 7009)
  9. 9. Bearer Token を API サーバーで検証する(Resource Server 側)
    1. 9.1 Authorization ヘッダの形式
    2. 9.2 Bearer 検証 middleware(自前 IdP / Cognito / Auth0 共通)
    3. 9.3 scope ベースの認可
    4. 9.4 ルートで使う
  10. 10. Client Credentials(サーバー間 OAuth)
    1. 10.1 ユースケース
    2. 10.2 トークン取得
    3. 10.3 M2M トークンで API を呼ぶ
  11. 11. openid-client で書き直す(現場の定番ライブラリ)
    1. 11.1 なぜ openid-client か
    2. 11.2 OIDC Discovery で IdP 設定を自動取得
    3. 11.3 openid-client での login / callback
  12. 12. Auth.js(NextAuth v5)で OAuth を Next.js に乗せる
    1. 12.1 インストール
    2. 12.2 auth.ts(App Router 用)
    3. 12.3 app/api/auth/[…nextauth]/route.ts
    4. 12.4 サーバーコンポーネントでセッション取得
  13. 13. iron-session・Lucia・マネージド IdP の使い分け
    1. 13.1 iron-session(ステートレス暗号化セッション)
    2. 13.2 Lucia Auth(セッション中心の軽量ライブラリ)
    3. 13.3 Clerk / Auth0 / WorkOS 比較
    4. 13.4 Clerk の最小コード
  14. 14. セキュリティ・OWASP・ベストプラクティス
    1. 14.1 リダイレクト URI ベストプラクティス
    2. 14.2 PKCE はなぜ Confidential Client にも必須なのか
    3. 14.3 トークンの保存場所
    4. 14.4 Cookie 設定の決定版
    5. 14.5 OWASP OAuth ベストプラクティス(要点)
    6. 14.6 mix-up 攻撃の対策
  15. 15. SAML との比較・関連仕様
    1. 15.1 SAML vs OIDC
    2. 15.2 関連 RFC・仕様
    3. 15.3 認可コードを使い回せない理由(one-time use)
  16. 16. テスト・運用・ハマりどころ
    1. 16.1 ローカルで HTTPS が必要な場合(mkcert)
    2. 16.2 Express で HTTPS 起動
    3. 16.3 Vitest で callback をテスト(モック JWKs)
    4. 16.4 よくあるエラーと原因
    5. 16.5 OIDC Discovery を curl で確かめる
    6. 16.6 ID Token を手元で覗く(本番では検証必須)
  17. 17. プライバシー・スコープ設計・運用観点
    1. 17.1 最小権限の原則(scope)
    2. 17.2 Google の incremental authorization
    3. 17.3 同意取り消しを受け止める(Google アカウント連携解除)
    4. 17.4 GDPR / 個人情報保護法の観点
    5. 17.5 ログから機微情報を伏せる(pino)
  18. 18. まとめと次に学ぶこと
    1. 18.1 この記事で押さえたポイント
    2. 18.2 次に読むべき関連記事(同サイト内)
    3. 18.3 学習を最短化したい場合(スクール紹介)

1. OAuth 2.0 と OpenID Connect:5 分で正しく理解する

1.1 OAuth 2.0 は「認可」、OpenID Connect は「認証」

最大の誤解は「OAuth = ログインの仕組み」と思うことです。OAuth 2.0(RFC 6749)は 認可(Authorization) の仕様、つまり「リソースへのアクセス権限の委譲」を扱います。一方 OpenID Connect(OIDC) は OAuth 2.0 の上に乗せた認証(Authentication)レイヤーで、「誰がログインしているか」を ID Token(JWT)で受け渡します。「Google でログイン」のような体験は厳密には OIDC を使っています。

1.2 4 つの登場人物(ロール)

  • Resource Owner:エンドユーザー(あなた)
  • Client:あなたのアプリ(SPA、モバイル、Web サーバー)
  • Authorization Server:Google・GitHub・Auth0 など、トークンを発行する側
  • Resource Server:API サーバー(Gmail API、GitHub API、自前 API)

1.3 フロー全体像(Authorization Code Flow + PKCE)

+----------+                                       +---------------+
|          | --(1) 認可リクエスト ----------------> |               |
|          |     (client_id, redirect_uri,         |  Authorization|
|          |      scope, state, code_challenge)    |     Server    |
|          |                                       |   (Google等)  |
|  Client  |  |
|          |     (code, code_verifier, client_id)        |
|          |  Resource Server
+----------+

1.4 環境準備(本記事のスタック)

# Node.js 22 LTS / TypeScript 5.6 / Express 5
node -v
# v22.11.0

mkdir oauth-handson && cd oauth-handson
npm init -y
npm i express@5 express-session cookie-parser cors helmet zod
npm i jose openid-client jsonwebtoken
npm i -D typescript @types/node @types/express @types/express-session 
  @types/cookie-parser tsx vitest

npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext 
  --esModuleInterop --strict --outDir dist

1.5 package.json の scripts

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest run"
  }
}

1.6 .env(本番ではシークレットマネージャへ)

# 共通
SESSION_SECRET=please-change-me-32bytes-or-more
APP_BASE_URL=http://localhost:3000

# Google
GOOGLE_CLIENT_ID=xxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxx
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/google/callback

# GitHub
GITHUB_CLIENT_ID=Iv1.xxxxxx
GITHUB_CLIENT_SECRET=xxxxxx
GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback

2. 4 つのフローと「2026 年に使ってよいもの」

2.1 4 種類のフロー一覧

フロー 用途 2026 推奨
Authorization Code + PKCE Web / SPA / モバイル ○ 標準
Client Credentials サーバー間 / M2M ○ 標準
Implicit 旧 SPA 向け × 廃止(OAuth 2.1)
Resource Owner Password パスワード直接渡し × 廃止(OAuth 2.1)
Device Code TV / CLI ○ 特殊用途

2.2 OAuth 2.1 ドラフトの主な変更

  • Implicit Flow と Password Flow を 正式廃止
  • Authorization Code Flow に PKCE を必須化(Public / Confidential 問わず)
  • redirect_uri は完全一致のみ(部分一致禁止)
  • Bearer Token の URI クエリ送信を非推奨化

2.3 なぜ Implicit Flow は廃止されたのか

Implicit はリダイレクトのフラグメントに access_token を直接乗せるため、ブラウザ履歴・リファラ・ログから漏れやすい欠陥がありました。SPA で「サーバーがないから」と Implicit を選ぶ時代は終わり、PKCE 付き Authorization Code Flow を SPA からも直接実行するのが現代の正解です。

3. Authorization Code Flow + PKCE をゼロから実装

3.1 PKCE(Proof Key for Code Exchange)とは

PKCE(ピクシー、RFC 7636)は 認可コード横取り攻撃を防ぐ仕組みです。クライアントは毎回ランダムな code_verifier を生成し、その SHA-256 ハッシュ(code_challenge)を /authorize に送ります。/token に進む際、元の code_verifier を提示することで「認可コードを取得した本人」であることを証明します。

3.2 code_verifier / code_challenge の生成(TypeScript)

// src/pkce.ts
import { randomBytes, createHash } from "node:crypto";

/** 43-128文字の[A-Z a-z 0-9 - . _ ~]のランダム文字列 */
export function generateCodeVerifier(length = 64): string {
  return randomBytes(length)
    .toString("base64url")
    .replace(/[^A-Za-z0-9-._~]/g, "")
    .slice(0, length);
}

/** SHA-256(verifier) を base64url した文字列 */
export function generateCodeChallenge(verifier: string): string {
  return createHash("sha256").update(verifier).digest("base64url");
}

/** state / nonce 用の128bitランダム */
export function generateRandomState(): string {
  return randomBytes(16).toString("base64url");
}

3.3 state と nonce の役割

  • state:CSRF 防御。リダイレクト先で同じ値を検証
  • nonce:OIDC 限定。リプレイ攻撃防御。ID Token に同じ値が入る
  • code_verifier:PKCE 用。認可コード横取り防御

3.4 セッション設定(express-session)

// src/session.ts
import session from "express-session";
import type { RequestHandler } from "express";

declare module "express-session" {
  interface SessionData {
    state?: string;
    nonce?: string;
    codeVerifier?: string;
    provider?: string;
    user?: { sub: string; email?: string; name?: string };
    tokens?: {
      access_token: string;
      refresh_token?: string;
      id_token?: string;
      expires_at: number;
    };
  }
}

export const sessionMiddleware: RequestHandler = session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
    maxAge: 1000 * 60 * 60 * 8,
  },
});

3.5 Express サーバー初期化

// src/server.ts
import express from "express";
import helmet from "helmet";
import cookieParser from "cookie-parser";
import { sessionMiddleware } from "./session.js";
import { googleRouter } from "./providers/google.js";
import { githubRouter } from "./providers/github.js";

const app = express();
app.use(helmet());
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(sessionMiddleware);

app.use("/auth/google", googleRouter);
app.use("/auth/github", githubRouter);

app.get("/", (req, res) => {
  if (req.session.user) {
    res.send(`

Hello, ${req.session.user.name ?? req.session.user.sub}

logout`); } else { res.send(`Login with Google
Login with GitHub`); } }); app.get("/auth/logout", (req, res) => { req.session.destroy(() => res.redirect("/")); }); app.listen(3000, () => console.log("http://localhost:3000"));

4. Google OAuth(OIDC)を Express で実装する

4.1 Google Cloud Console での設定

  1. GCP コンソール → APIs & Services → OAuth consent screen を「External」で作成
  2. Credentials → Create credentials → OAuth client ID → Web application
  3. Authorized redirect URIs に http://localhost:3000/auth/google/callback完全一致で追加
  4. 表示された Client ID / Client Secret を .env に保存

4.2 Google の OIDC エンドポイント

// src/providers/google-endpoints.ts
// OIDC Discovery: https://accounts.google.com/.well-known/openid-configuration
export const GOOGLE = {
  authorization: "https://accounts.google.com/o/oauth2/v2/auth",
  token: "https://oauth2.googleapis.com/token",
  userinfo: "https://openidconnect.googleapis.com/v1/userinfo",
  jwks: "https://www.googleapis.com/oauth2/v3/certs",
  issuer: "https://accounts.google.com",
  revocation: "https://oauth2.googleapis.com/revoke",
} as const;

4.3 /auth/google/login(認可リクエスト)

// src/providers/google.ts
import { Router } from "express";
import { GOOGLE } from "./google-endpoints.js";
import {
  generateCodeVerifier,
  generateCodeChallenge,
  generateRandomState,
} from "../pkce.js";

export const googleRouter = Router();

googleRouter.get("/login", (req, res) => {
  const state = generateRandomState();
  const nonce = generateRandomState();
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);

  req.session.state = state;
  req.session.nonce = nonce;
  req.session.codeVerifier = codeVerifier;
  req.session.provider = "google";

  const params = new URLSearchParams({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
    response_type: "code",
    scope: "openid email profile",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    access_type: "offline",   // refresh_token を得るため
    prompt: "consent",         // 同意画面を毎回出すと refresh_token が出やすい
  });

  res.redirect(`${GOOGLE.authorization}?${params.toString()}`);
});

4.4 /auth/google/callback(トークン交換)

// src/providers/google.ts (続き)
import { verifyIdToken } from "../jwt-verify.js";

googleRouter.get("/callback", async (req, res) => {
  const { code, state, error } = req.query as Record;
  if (error) return res.status(400).send(`OAuth error: ${error}`);
  if (!code) return res.status(400).send("no code");
  if (!state || state !== req.session.state) {
    return res.status(400).send("invalid state");
  }
  const codeVerifier = req.session.codeVerifier;
  if (!codeVerifier) return res.status(400).send("no code_verifier");

  // /token への POST(application/x-www-form-urlencoded)
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    client_id: process.env.GOOGLE_CLIENT_ID!,
    client_secret: process.env.GOOGLE_CLIENT_SECRET!,
    redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
    code_verifier: codeVerifier,
  });

  const tokenRes = await fetch(GOOGLE.token, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body,
  });
  if (!tokenRes.ok) {
    const t = await tokenRes.text();
    return res.status(400).send(`token error: ${t}`);
  }
  const tokens = (await tokenRes.json()) as {
    access_token: string;
    expires_in: number;
    refresh_token?: string;
    id_token: string;
    scope: string;
    token_type: "Bearer";
  };

  // ID Token を検証して subject を取り出す
  const claims = await verifyIdToken(tokens.id_token, {
    issuer: GOOGLE.issuer,
    audience: process.env.GOOGLE_CLIENT_ID!,
    jwksUri: GOOGLE.jwks,
    nonce: req.session.nonce!,
  });

  req.session.user = {
    sub: claims.sub as string,
    email: claims.email as string | undefined,
    name: claims.name as string | undefined,
  };
  req.session.tokens = {
    access_token: tokens.access_token,
    refresh_token: tokens.refresh_token,
    id_token: tokens.id_token,
    expires_at: Date.now() + tokens.expires_in * 1000,
  };

  // 使い終わったワンタイム値を消す
  delete req.session.state;
  delete req.session.nonce;
  delete req.session.codeVerifier;

  res.redirect("/");
});

4.5 UserInfo エンドポイントを呼ぶ

// src/providers/google.ts (続き)
googleRouter.get("/me", async (req, res) => {
  const token = req.session.tokens?.access_token;
  if (!token) return res.status(401).send("login first");

  const r = await fetch(GOOGLE.userinfo, {
    headers: { authorization: `Bearer ${token}` },
  });
  if (!r.ok) return res.status(r.status).send(await r.text());
  res.json(await r.json());
});

5. ID Token(JWT)を jose で安全に検証する

5.1 なぜ ID Token の検証が必要か

ID Token は JWT で署名されていますが、署名検証・iss・aud・exp・nonce のすべてを確認しないと、別アプリ向けに発行されたトークンを取り違える事故が発生します。jsonwebtokenjwt.decode() だけで終わらせるのは絶対に避けてください(署名未検証のため)。

5.2 JWKs を取得して鍵を回す(jose)

// src/jwt-verify.ts
import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";

type VerifyOpts = {
  issuer: string;
  audience: string;
  jwksUri: string;
  nonce?: string;
};

const jwksCache = new Map<string, ReturnType>();

function getJWKS(uri: string) {
  let jwks = jwksCache.get(uri);
  if (!jwks) {
    jwks = createRemoteJWKSet(new URL(uri), {
      cacheMaxAge: 10 * 60 * 1000,   // 10分キャッシュ
      cooldownDuration: 30 * 1000,
    });
    jwksCache.set(uri, jwks);
  }
  return jwks;
}

export async function verifyIdToken(
  idToken: string,
  opts: VerifyOpts,
): Promise {
  const { payload } = await jwtVerify(idToken, getJWKS(opts.jwksUri), {
    issuer: opts.issuer,
    audience: opts.audience,
    algorithms: ["RS256", "ES256"], // alg混同攻撃防止のためホワイトリスト
  });

  // nonce のチェックは jose が自動でやらないので自前で
  if (opts.nonce && payload.nonce !== opts.nonce) {
    throw new Error("nonce mismatch");
  }
  return payload;
}

5.3 alg 混同攻撃(none / HS256 すり替え)を防ぐ

jose の algorithms オプションでホワイトリスト指定すれば、攻撃者が alg: none や対称鍵 HS256 にすり替えた JWT を弾けます。絶対に algorithms を省略しないでください

5.4 ID Token の主要クレーム

// ID Token (decoded payload example)
{
  "iss": "https://accounts.google.com",
  "azp": "xxx.apps.googleusercontent.com",
  "aud": "xxx.apps.googleusercontent.com",
  "sub": "111111111111111111111",
  "email": "user@example.com",
  "email_verified": true,
  "at_hash": "abc...",
  "name": "Yamada Taro",
  "picture": "https://lh3.googleusercontent.com/...",
  "given_name": "Taro",
  "family_name": "Yamada",
  "iat": 1727500000,
  "exp": 1727503600,
  "nonce": "the-nonce-we-sent"
}

6. GitHub OAuth を実装する(OAuth 2.0 のみ・ID Token なし)

6.1 GitHub と Google の違い

GitHub の OAuth Apps は OAuth 2.0 のみに対応し、OIDC ではないため ID Token は返りません。ユーザー情報は /user API を Bearer Token で叩いて取得します。

6.2 GitHub Developer Settings での設定

  1. Settings → Developer settings → OAuth Apps → New OAuth App
  2. Homepage URL に http://localhost:3000、Authorization callback URL に http://localhost:3000/auth/github/callback
  3. Client secrets を生成し .env に保存

6.3 /auth/github/login

// src/providers/github.ts
import { Router } from "express";
import { generateRandomState } from "../pkce.js";

export const githubRouter = Router();

const GH = {
  authorization: "https://github.com/login/oauth/authorize",
  token: "https://github.com/login/oauth/access_token",
  userinfo: "https://api.github.com/user",
};

githubRouter.get("/login", (req, res) => {
  const state = generateRandomState();
  req.session.state = state;
  req.session.provider = "github";

  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: process.env.GITHUB_REDIRECT_URI!,
    state,
    scope: "read:user user:email",
    allow_signup: "true",
  });
  res.redirect(`${GH.authorization}?${params}`);
});

6.4 /auth/github/callback

// src/providers/github.ts (続き)
githubRouter.get("/callback", async (req, res) => {
  const { code, state } = req.query as Record;
  if (!code || !state || state !== req.session.state) {
    return res.status(400).send("invalid state");
  }

  const tokenRes = await fetch(GH.token, {
    method: "POST",
    headers: {
      accept: "application/json",
      "content-type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      client_id: process.env.GITHUB_CLIENT_ID!,
      client_secret: process.env.GITHUB_CLIENT_SECRET!,
      code,
      redirect_uri: process.env.GITHUB_REDIRECT_URI!,
    }),
  });
  const tokens = (await tokenRes.json()) as {
    access_token: string;
    token_type: "bearer";
    scope: string;
  };

  // 自前で UserInfo を取りに行く
  const u = await fetch(GH.userinfo, {
    headers: {
      authorization: `Bearer ${tokens.access_token}`,
      accept: "application/vnd.github+json",
      "user-agent": "oauth-handson",
    },
  });
  const user = await u.json() as { id: number; login: string; name?: string };

  req.session.user = {
    sub: `github:${user.id}`,
    name: user.name ?? user.login,
  };
  req.session.tokens = {
    access_token: tokens.access_token,
    expires_at: Date.now() + 1000 * 60 * 60 * 8,
  };
  delete req.session.state;
  res.redirect("/");
});

7. X(Twitter)OAuth 2.0 と Apple Sign-In

7.1 X(旧 Twitter)OAuth 2.0 with PKCE

X は 2021 年に OAuth 2.0 + PKCE に対応しました。Confidential Client(Web サーバー)でも PKCE 必須です。

// src/providers/x.ts
import { Router } from "express";
import {
  generateCodeVerifier,
  generateCodeChallenge,
  generateRandomState,
} from "../pkce.js";

export const xRouter = Router();

const X = {
  authorization: "https://twitter.com/i/oauth2/authorize",
  token: "https://api.twitter.com/2/oauth2/token",
  userinfo: "https://api.twitter.com/2/users/me",
};

xRouter.get("/login", (req, res) => {
  const state = generateRandomState();
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);

  req.session.state = state;
  req.session.codeVerifier = codeVerifier;

  const params = new URLSearchParams({
    client_id: process.env.X_CLIENT_ID!,
    redirect_uri: process.env.X_REDIRECT_URI!,
    response_type: "code",
    scope: "tweet.read users.read offline.access",
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });
  res.redirect(`${X.authorization}?${params}`);
});

7.2 X のトークン交換(Basic 認証ヘッダ)

xRouter.get("/callback", async (req, res) => {
  const { code, state } = req.query as Record;
  if (!code || state !== req.session.state) {
    return res.status(400).send("invalid state");
  }
  const basic = Buffer.from(
    `${process.env.X_CLIENT_ID}:${process.env.X_CLIENT_SECRET}`,
  ).toString("base64");

  const r = await fetch(X.token, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: `Basic ${basic}`,
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: process.env.X_REDIRECT_URI!,
      code_verifier: req.session.codeVerifier!,
    }),
  });
  const tokens = await r.json();
  // 以降は同様
  res.redirect("/");
});

7.3 Apple Sign-In(Sign in with Apple)

Apple は OIDC 準拠ですが、クライアントシークレットを JWT で動的生成する独特の方式です。ES256(P-256)で署名した JWT を client_secret として送ります。

// src/providers/apple-secret.ts
import { SignJWT, importPKCS8 } from "jose";

export async function buildAppleClientSecret(opts: {
  teamId: string;
  clientId: string; // services id
  keyId: string;
  privateKeyPem: string;
}) {
  const key = await importPKCS8(opts.privateKeyPem, "ES256");
  return new SignJWT({})
    .setProtectedHeader({ alg: "ES256", kid: opts.keyId })
    .setIssuer(opts.teamId)
    .setIssuedAt()
    .setExpirationTime("180d")
    .setAudience("https://appleid.apple.com")
    .setSubject(opts.clientId)
    .sign(key);
}

7.4 Apple OIDC エンドポイント

// src/providers/apple-endpoints.ts
export const APPLE = {
  authorization: "https://appleid.apple.com/auth/authorize",
  token: "https://appleid.apple.com/auth/token",
  jwks: "https://appleid.apple.com/auth/keys",
  issuer: "https://appleid.apple.com",
} as const;

8. Refresh Token・Introspection・Revocation

8.1 Refresh Token とは

access_token は短命(通常 1 時間)です。失効時に毎回ユーザーに再認可させると UX が破綻するため、Authorization Server が長命の refresh_token を一緒に発行します。Refresh Token を使って access_token を再発行する仕組みです。

8.2 Refresh Token で access_token を更新

// src/refresh.ts
import { GOOGLE } from "./providers/google-endpoints.js";

export async function refreshGoogleAccessToken(refreshToken: string) {
  const r = await fetch(GOOGLE.token, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  });
  if (!r.ok) throw new Error(`refresh failed: ${await r.text()}`);
  return (await r.json()) as {
    access_token: string;
    expires_in: number;
    scope: string;
    token_type: "Bearer";
    id_token?: string;
    // 一部のIdPは refresh_token も rotate して再発行する
    refresh_token?: string;
  };
}

8.3 Token を自動更新する middleware

// src/middleware/ensure-token.ts
import type { RequestHandler } from "express";
import { refreshGoogleAccessToken } from "../refresh.js";

export const ensureFreshToken: RequestHandler = async (req, res, next) => {
  const t = req.session.tokens;
  if (!t) return res.status(401).send("login required");
  if (Date.now() < t.expires_at - 60_000) return next();
  if (!t.refresh_token) return res.status(401).send("re-login required");

  try {
    const fresh = await refreshGoogleAccessToken(t.refresh_token);
    req.session.tokens = {
      access_token: fresh.access_token,
      refresh_token: fresh.refresh_token ?? t.refresh_token,
      id_token: fresh.id_token ?? t.id_token,
      expires_at: Date.now() + fresh.expires_in * 1000,
    };
    next();
  } catch (e) {
    res.status(401).send("token refresh failed");
  }
};

8.4 Token Introspection(RFC 7662)

Introspection は、Resource Server が「もらった access_token が今も有効か」を Authorization Server に問い合わせる仕組みです。自前 IdP(Keycloak など)や Auth0 で利用できます。

// src/introspect.ts
type IntrospectResult = {
  active: boolean;
  scope?: string;
  client_id?: string;
  username?: string;
  exp?: number;
  iat?: number;
  sub?: string;
};

export async function introspect(token: string): Promise {
  const basic = Buffer.from(
    `${process.env.IDP_CLIENT_ID}:${process.env.IDP_CLIENT_SECRET}`,
  ).toString("base64");

  const r = await fetch(process.env.IDP_INTROSPECT_URL!, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: `Basic ${basic}`,
    },
    body: new URLSearchParams({ token, token_type_hint: "access_token" }),
  });
  return (await r.json()) as IntrospectResult;
}

8.5 Token Revocation(RFC 7009)

// src/revoke.ts
import { GOOGLE } from "./providers/google-endpoints.js";

export async function revokeGoogleToken(token: string) {
  const r = await fetch(GOOGLE.revocation, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ token }),
  });
  if (!r.ok) throw new Error(`revoke failed: ${await r.text()}`);
}

9. Bearer Token を API サーバーで検証する(Resource Server 側)

9.1 Authorization ヘッダの形式

GET /api/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/json

9.2 Bearer 検証 middleware(自前 IdP / Cognito / Auth0 共通)

// src/middleware/bearer.ts
import type { RequestHandler } from "express";
import { jwtVerify, createRemoteJWKSet } from "jose";

const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URI!));

declare module "express-serve-static-core" {
  interface Request {
    user?: { sub: string; scope: string[] };
  }
}

export const requireBearer: RequestHandler = async (req, res, next) => {
  const h = req.headers.authorization;
  if (!h?.startsWith("Bearer ")) {
    return res
      .status(401)
      .set("www-authenticate", `Bearer realm="api"`)
      .send("missing bearer");
  }
  const token = h.slice("Bearer ".length).trim();
  try {
    const { payload } = await jwtVerify(token, jwks, {
      issuer: process.env.ISSUER!,
      audience: process.env.AUDIENCE!,
      algorithms: ["RS256", "ES256"],
    });
    req.user = {
      sub: payload.sub as string,
      scope: (payload.scope as string)?.split(" ") ?? [],
    };
    next();
  } catch (e) {
    res
      .status(401)
      .set("www-authenticate", `Bearer error="invalid_token"`)
      .send("invalid token");
  }
};

9.3 scope ベースの認可

// src/middleware/scope.ts
import type { RequestHandler } from "express";

export function requireScope(needed: string): RequestHandler {
  return (req, res, next) => {
    if (!req.user) return res.status(401).send("unauthorized");
    if (!req.user.scope.includes(needed)) {
      return res
        .status(403)
        .set(
          "www-authenticate",
          `Bearer error="insufficient_scope", scope="${needed}"`,
        )
        .send("forbidden");
    }
    next();
  };
}

9.4 ルートで使う

// src/api.ts
import { Router } from "express";
import { requireBearer } from "./middleware/bearer.js";
import { requireScope } from "./middleware/scope.js";

export const api = Router();
api.use(requireBearer);

api.get("/me", (req, res) => res.json({ sub: req.user!.sub }));
api.post("/admin", requireScope("admin"), (req, res) => res.send("ok"));

10. Client Credentials(サーバー間 OAuth)

10.1 ユースケース

バッチ処理・cron・マイクロサービス間通信など、ユーザーが介在しないサーバー間 API 呼び出しで使います。ユーザーの代理ではなく、クライアント(アプリ)自身が主体です。

10.2 トークン取得

// src/m2m.ts
type M2MToken = { access_token: string; expires_in: number };
let cache: { token: string; expAt: number } | null = null;

export async function getM2MToken(): Promise {
  if (cache && Date.now() < cache.expAt - 60_000) return cache.token;

  const basic = Buffer.from(
    `${process.env.M2M_CLIENT_ID}:${process.env.M2M_CLIENT_SECRET}`,
  ).toString("base64");

  const r = await fetch(process.env.M2M_TOKEN_URL!, {
    method: "POST",
    headers: {
      authorization: `Basic ${basic}`,
      "content-type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      scope: "service:read service:write",
      audience: process.env.M2M_AUDIENCE!, // Auth0/Cognito等で必要
    }),
  });
  if (!r.ok) throw new Error(`m2m failed: ${await r.text()}`);
  const j = (await r.json()) as M2MToken;
  cache = { token: j.access_token, expAt: Date.now() + j.expires_in * 1000 };
  return j.access_token;
}

10.3 M2M トークンで API を呼ぶ

// src/m2m-call.ts
import { getM2MToken } from "./m2m.js";

export async function callInternalApi(path: string) {
  const token = await getM2MToken();
  const r = await fetch(`https://internal.example.com${path}`, {
    headers: { authorization: `Bearer ${token}` },
  });
  return r.json();
}

11. openid-client で書き直す(現場の定番ライブラリ)

11.1 なぜ openid-client か

ここまでは学習目的でフルスクラッチで書きましたが、本番では Filip Skokan 氏の openid-client を使うのが定番です。OIDC Discovery、JWKs キャッシュ、PKCE、nonce 検証、JAR / JARM までカバーし、認定実装(Certified)でもあります。

11.2 OIDC Discovery で IdP 設定を自動取得

// src/oidc.ts
import { Issuer, generators, Client } from "openid-client";

export async function buildGoogleClient(): Promise {
  const google = await Issuer.discover("https://accounts.google.com");
  return new google.Client({
    client_id: process.env.GOOGLE_CLIENT_ID!,
    client_secret: process.env.GOOGLE_CLIENT_SECRET!,
    redirect_uris: [process.env.GOOGLE_REDIRECT_URI!],
    response_types: ["code"],
  });
}

export { generators };

11.3 openid-client での login / callback

// src/providers/google-oidcclient.ts
import { Router } from "express";
import { buildGoogleClient, generators } from "../oidc.js";

export const r = Router();
const clientPromise = buildGoogleClient();

r.get("/login", async (req, res) => {
  const client = await clientPromise;
  const codeVerifier = generators.codeVerifier();
  const codeChallenge = generators.codeChallenge(codeVerifier);
  const state = generators.state();
  const nonce = generators.nonce();
  req.session.codeVerifier = codeVerifier;
  req.session.state = state;
  req.session.nonce = nonce;

  res.redirect(
    client.authorizationUrl({
      scope: "openid email profile",
      code_challenge: codeChallenge,
      code_challenge_method: "S256",
      state,
      nonce,
      access_type: "offline",
      prompt: "consent",
    }),
  );
});

r.get("/callback", async (req, res) => {
  const client = await clientPromise;
  const params = client.callbackParams(req);
  const tokenSet = await client.callback(
    process.env.GOOGLE_REDIRECT_URI!,
    params,
    {
      code_verifier: req.session.codeVerifier,
      state: req.session.state,
      nonce: req.session.nonce,
    },
  );
  const claims = tokenSet.claims();
  req.session.user = {
    sub: claims.sub,
    email: claims.email,
    name: claims.name,
  };
  req.session.tokens = {
    access_token: tokenSet.access_token!,
    refresh_token: tokenSet.refresh_token,
    id_token: tokenSet.id_token,
    expires_at: tokenSet.expires_at! * 1000,
  };
  res.redirect("/");
});

12. Auth.js(NextAuth v5)で OAuth を Next.js に乗せる

12.1 インストール

npm i next-auth@beta

12.2 auth.ts(App Router 用)

// auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: { params: { scope: "openid email profile" } },
    }),
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at;
      }
      return token;
    },
    async session({ session, token }) {
      (session as any).accessToken = token.accessToken;
      return session;
    },
  },
});

12.3 app/api/auth/[…nextauth]/route.ts

// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";

12.4 サーバーコンポーネントでセッション取得

// app/page.tsx
import { auth, signIn, signOut } from "@/auth";

export default async function Home() {
  const session = await auth();
  if (!session) {
    return (
       { "use server"; await signIn("google"); }}>
        
      
    );
  }
  return (
    
      

Hello, {session.user?.name}

{ "use server"; await signOut(); }}>

13. iron-session・Lucia・マネージド IdP の使い分け

13.1 iron-session(ステートレス暗号化セッション)

// src/iron.ts
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";

type AppSession = { user?: { sub: string; name?: string } };

const opts: SessionOptions = {
  password: process.env.IRON_PASSWORD!, // 32文字以上
  cookieName: "app_session",
  cookieOptions: { secure: true, httpOnly: true, sameSite: "lax" },
};

export async function getSession() {
  return getIronSession(await cookies(), opts);
}

13.2 Lucia Auth(セッション中心の軽量ライブラリ)

// src/lucia.ts (Lucia v3)
import { Lucia } from "lucia";
import { BetterSqlite3Adapter } from "@lucia-auth/adapter-sqlite";
import Database from "better-sqlite3";

const db = new Database("auth.db");
const adapter = new BetterSqlite3Adapter(db, {
  user: "user",
  session: "session",
});

export const lucia = new Lucia(adapter, {
  sessionCookie: { attributes: { secure: true } },
  getUserAttributes: (data) => ({
    googleId: data.google_id,
    name: data.name,
  }),
});

13.3 Clerk / Auth0 / WorkOS 比較

サービス 強み 向き
Clerk Next.js統合・組織機能・UIコンポ豊富 B2C SaaS の高速立ち上げ
Auth0 老舗・Rules/Actions柔軟・SAML対応 エンタープライズ全般
WorkOS B2B SSO/SCIM特化・SAML楽 大企業向け SaaS
Supabase Auth DBと一体・Row Level Security連携 Postgres中心アプリ

13.4 Clerk の最小コード

// app/layout.tsx (Next.js)
import { ClerkProvider, SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    
      
        
          
          
          {children}
        
      
    
  );
}

14. セキュリティ・OWASP・ベストプラクティス

14.1 リダイレクト URI ベストプラクティス

  • 完全一致のみ(ワイルドカード・パスプレフィックス禁止)
  • 本番は https のみ、開発は http://localhost のみ許容
  • 動的 redirect は state パラメータの中に入れて検証する(open redirect 対策)
  • OAuth 2.1 では完全一致が必須化された

14.2 PKCE はなぜ Confidential Client にも必須なのか

従来 PKCE は SPA・モバイルアプリ(Public Client)向けの仕様でしたが、Confidential Client でも 認可コード横取り攻撃を防ぐ価値があるため、OAuth 2.1 ドラフトと FAPI ですべてのクライアントに必須化されました。「サーバーが持ってる秘密鍵があるから PKCE 不要」という時代は終わっています。

14.3 トークンの保存場所

保存先 XSS CSRF 推奨
localStorage 脆弱 影響なし ×
JS可読 cookie 脆弱 対策必要 ×
HttpOnly+Secure cookie 安全 SameSite=Lax/Strict で対策
サーバー側セッション 安全 対策容易

14.4 Cookie 設定の決定版

// src/cookie.ts
export const SECURE_COOKIE = {
  httpOnly: true,
  secure: true,         // 本番は必ず true
  sameSite: "lax" as const, // OAuth 戻りのため strict は避ける
  path: "/",
  maxAge: 1000 * 60 * 60 * 8,
} as const;

14.5 OWASP OAuth ベストプラクティス(要点)

  • state パラメータを必ず検証する(CSRF / mix-up 対策)
  • nonce を ID Token と照合する(リプレイ対策)
  • PKCE S256(plain は使わない)
  • scope は最小権限で要求する
  • refresh_token は rotate(IdP 側設定)で再利用検知
  • id_token は localStorage に置かない
  • access_token を URL クエリで送らない(ログ漏れ防止)

14.6 mix-up 攻撃の対策

複数 IdP に同時にログインボタンを置く場合、攻撃者が「Google のつもりで認可コードを GitHub へ渡す」ような mix-up 攻撃があり得ます。state にプロバイダ識別子を入れて検証してください。

// src/state.ts
import { createHmac } from "node:crypto";

export function signedState(payload: { provider: string; r: string }) {
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
  const sig = createHmac("sha256", process.env.STATE_SECRET!)
    .update(body)
    .digest("base64url");
  return `${body}.${sig}`;
}

export function verifySignedState(token: string) {
  const [body, sig] = token.split(".");
  const expected = createHmac("sha256", process.env.STATE_SECRET!)
    .update(body)
    .digest("base64url");
  if (expected !== sig) throw new Error("invalid state signature");
  return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
}

15. SAML との比較・関連仕様

15.1 SAML vs OIDC

観点 SAML 2.0 OIDC
フォーマット XML JSON / JWT
主な用途 エンタープライズ SSO Web/モバイル/API
主な経路 HTTP POST Binding HTTP Redirect + POST
モバイル対応 弱い 得意
採用 Okta/AD FS/大手SaaS モダン全般

15.2 関連 RFC・仕様

  • RFC 6749 - OAuth 2.0 Authorization Framework
  • RFC 6750 - Bearer Token Usage
  • RFC 7009 - Token Revocation
  • RFC 7519 - JSON Web Token (JWT)
  • RFC 7636 - PKCE
  • RFC 7662 - Token Introspection
  • RFC 8252 - OAuth 2.0 for Native Apps
  • RFC 8414 - OAuth 2.0 Authorization Server Metadata
  • RFC 8628 - Device Authorization Grant
  • RFC 8693 - Token Exchange
  • OpenID Connect Core 1.0 / Discovery 1.0

15.3 認可コードを使い回せない理由(one-time use)

認可コードは 一度だけ使えます。二度目の /token 交換は Authorization Server がエラーを返し、さらに最初に発行したアクセストークンも 失効させるのが推奨実装です(RFC 6749 4.1.2)。コードが漏れたかもしれない場合は強制ログアウトに繋げます。

16. テスト・運用・ハマりどころ

16.1 ローカルで HTTPS が必要な場合(mkcert)

brew install mkcert            # macOS
mkcert -install
mkcert localhost
# localhost.pem / localhost-key.pem が生成される

16.2 Express で HTTPS 起動

// src/https.ts
import https from "node:https";
import fs from "node:fs";
import app from "./server.js";

https
  .createServer(
    {
      key: fs.readFileSync("localhost-key.pem"),
      cert: fs.readFileSync("localhost.pem"),
    },
    app,
  )
  .listen(3443, () => console.log("https://localhost:3443"));

16.3 Vitest で callback をテスト(モック JWKs)

// tests/jwt-verify.test.ts
import { describe, it, expect } from "vitest";
import { SignJWT, generateKeyPair, exportJWK } from "jose";

describe("verifyIdToken", () => {
  it("rejects mismatched audience", async () => {
    const { publicKey, privateKey } = await generateKeyPair("RS256");
    const jwt = await new SignJWT({ nonce: "n" })
      .setProtectedHeader({ alg: "RS256", kid: "test" })
      .setIssuer("https://test")
      .setAudience("wrong-aud")
      .setSubject("u1")
      .setExpirationTime("5m")
      .sign(privateKey);
    // 検証 (audience: 'right-aud' を期待) は失敗するはず
    expect(jwt).toBeTruthy();
  });
});

16.4 よくあるエラーと原因

症状 原因
redirect_uri_mismatch Console 登録 URI と完全一致していない(末尾スラッシュ/ポート違い)
invalid_grant code 期限切れ・二回交換・code_verifier 不一致
refresh_token が来ない access_type=offline と prompt=consent が無い(Google)
nonce mismatch セッション側 nonce が消えている(セッション cookie が cross-site で落ちている)
audience invalid jwtVerify の audience に client_id を渡し忘れ

16.5 OIDC Discovery を curl で確かめる

curl -s https://accounts.google.com/.well-known/openid-configuration | jq
# 重要キー
# - authorization_endpoint
# - token_endpoint
# - jwks_uri
# - issuer
# - userinfo_endpoint
# - revocation_endpoint
# - code_challenge_methods_supported (S256 必須)

16.6 ID Token を手元で覗く(本番では検証必須)

# jwt CLI(npm i -g jwt-cli)で payload を眺める
echo "$ID_TOKEN" | jwt decode -
# あるいは Node ワンライナー
node -e 'const t=process.argv[1].split(".")[1];
console.log(JSON.parse(Buffer.from(t,"base64url").toString()))' $ID_TOKEN

17. プライバシー・スコープ設計・運用観点

17.1 最小権限の原則(scope)

「とりあえず全 scope を要求」する設計は、ユーザーの同意率を下げ、漏洩時のリスクを増やします。機能が必要になった時点で incremental authorization で追加要求するのが Google が推奨する設計です。

17.2 Google の incremental authorization

// 後から追加スコープを要求(include_granted_scopes)
const params = new URLSearchParams({
  client_id: process.env.GOOGLE_CLIENT_ID!,
  redirect_uri: process.env.GOOGLE_REDIRECT_URI!,
  response_type: "code",
  scope: "openid email profile https://www.googleapis.com/auth/drive.readonly",
  state, nonce,
  code_challenge: cc, code_challenge_method: "S256",
  include_granted_scopes: "true",
});

17.3 同意取り消しを受け止める(Google アカウント連携解除)

ユーザーが Google アカウント設定からアプリ連携を解除すると refresh_token が無効化されます。定期実行ジョブで refresh エラーを検知し、ユーザーに再ログインを促す UX を組み込みましょう。

17.4 GDPR / 個人情報保護法の観点

  • id_token に含まれる email / name はそれ自体が個人情報
  • ログに ID Token を出さない(redact 必須)
  • サブジェクト識別子は sub のみで保存し、表示は最小化
  • アカウント削除時はトークン Revocation と DB 削除を両方

17.5 ログから機微情報を伏せる(pino)

// src/logger.ts
import pino from "pino";

export const logger = pino({
  redact: {
    paths: [
      "*.access_token",
      "*.id_token",
      "*.refresh_token",
      "*.code",
      "*.code_verifier",
      "headers.authorization",
      "headers.cookie",
    ],
    censor: "[REDACTED]",
  },
});

18. まとめと次に学ぶこと

18.1 この記事で押さえたポイント

  • OAuth 2.0 は 認可、OIDC は 認証(ID Token を返す)
  • 2026 年現在の標準は Authorization Code Flow + PKCE 一択
  • state / nonce / code_verifier をセッションに保管し、callback で必ず検証
  • ID Token は jose で JWKs + algorithms ホワイトリスト + iss/aud/nonce を検証
  • 本番は openid-client / Auth.js / Clerk / Auth0 などの実装に乗る
  • トークンはサーバー側セッションか HttpOnly+Secure cookie に保管

18.2 次に読むべき関連記事(同サイト内)

18.3 学習を最短化したい場合(スクール紹介)

OAuth / OIDC は実装を間違えると即セキュリティ事故になるため、独学だけでなく 現役エンジニアのレビューを受けながら手を動かすのが安全です。以下のスクールは認証認可や Web セキュリティを含む実践課題を提供しています。

  • テックアカデミー:オンライン完結・週 2 回のメンタリングで OAuth 実装課題までフォロー
  • 侍エンジニア:オーダーメイドカリキュラムで認証認可・API 設計を集中的に学べる
  • DMM WEBCAMP:現役エンジニアによる質問対応無制限、実務想定の課題が豊富
  • レバテックカレッジ:大学生向け短期集中、Web セキュリティ基礎まで学習可能

「ハンズオンで OAuth/OIDC を扱う認証認可レイヤーをポートフォリオに載せる」だけで、Web エンジニア採用面接の通過率は明確に上がります。本記事のコードをベースに、Google + GitHub の 2 系統ログインを実装して GitHub に push しておくのを強くおすすめします。

本記事のチェックリスト(コピペで使えます)

  • [ ] Authorization Code Flow + PKCE を実装した
  • [ ] state / nonce / code_verifier をセッションに保管・検証している
  • [ ] ID Token を jose で JWKs 検証・algorithms ホワイトリスト指定
  • [ ] redirect_uri は完全一致(本番 https のみ)
  • [ ] Cookie は HttpOnly + Secure + SameSite=Lax
  • [ ] refresh_token を rotate 設定で運用
  • [ ] Token Revocation を logout 時に呼んでいる
  • [ ] ログから access_token / id_token / refresh_token を redact
  • [ ] 最小権限の scope のみ要求(incremental authorization 活用)
  • [ ] Vitest で nonce/audience/expiration の境界ケースを回している

コメント

タイトルとURLをコピーしました