Claude Media
Claude CodeでOAuth実装 — PKCE・state・リダイレクトURIをテストで固める

Claude CodeでOAuth実装 — PKCE・state・リダイレクトURIをテストで固める

Claude Codeで認可コードフローのクライアントを実装する手順。state不一致・code_verifier欠落・redirect_uri改ざんを拒否するテストを先に書かせ、client_secretを会話に出さない設定も扱います。

Claude CodeでOAuth 2.0のクライアントを実装するときは、コードより先に「拒否すべき入力」をテストにして渡すのが近道です。認可コードフローは、正常系なら一度動いてしまいます。壊れるのは、stateの不一致、code_verifier の欠落、redirect_uri の改ざんといった異常系です。この記事では、その異常系をNode.jsの標準テストランナーで固定し、Claude Codeにテストが通るまで実装させる流れを、動かして確かめたコード付きで説明します。後半では、client_secret を会話に出さないための permissions 設定と、その限界も扱います。

認可コードフローで取りこぼしやすい4点

認可コードフローのクライアントが守る点は、RFCで明文化されています。実装を頼む前に、何を拒否させたいのかを4つに分けておくと、テストの粒度が決まります。

拒否条件

クライアント側で拒否する4点

  • state の不一致

    認可サーバーから戻ってきた state が、送ったものと違うリクエストです。リダイレクト先に攻撃者が自分の認可コードを注入するCSRFを防ぐ要件で、RFC 6749はクライアントにCSRF対策をMUSTとしています。

  • code_verifier の欠落

    PKCEのトークン要求に code_verifier が無い状態です。RFC 7636ではトークン要求でREQUIREDのパラメーターです。

  • redirect_uri の不一致

    登録したURIと1文字でも違う値です。RFC 9700は、登録済みURIとの比較を完全な文字列一致で行うことを認可サーバーに求めています。

  • error 応答の素通し

    error=access_denied のような応答に code が付いてくる場合です。エラーを見落とすと、未認可のままトークン交換へ進みます。

RFC 9700は、公開クライアントにはPKCEをMUSTとし、機密クライアントにも使うことをRECOMMENDEDとしています。ネイティブアプリやSPAだけでなく、サーバーサイドのクライアントでもPKCEを入れておく設計が標準的です。

実装より先にテストを書かせる

Claude Codeのベストプラクティスは、合否が返ってくる確認手段を持たせると、Claudeが変更、実行、失敗の読み取り、再実行の循環を自分で回せると説明しています。確認手段は、テストファイルでも、ビルドの終了コードでも構いません。OAuthのようにセキュリティ要件が決まっている領域では、テストが要件そのものの写しになります。

CLAUDE.mdには、実装の方針を数行で書いておきます。

# OAuth クライアントの規約
- 認可コードフローは PKCE(S256 のみ)と state を必ず併用する
- テストは `node --test` で実行する。異常系のテストを先に書き、失敗を確認してから実装する
- client_secret は process.env.OAUTH_CLIENT_SECRET からだけ読む。コード・テスト・ログに値を書かない
- redirect_uri は設定値と完全一致で比較する。前方一致・部分一致は使わない

最初のプロンプトは、拒否条件を列挙し、実装を後回しにする指示にします。

oauth-client.mjs を新規に作る。まず oauth-client.test.mjs だけを書いて。
拒否させたいケースは次の4つ。
1. state が無い・違う・再利用された
2. code_verifier が無い状態でトークン要求を組み立てようとした
3. redirect_uri が登録値と完全一致しない(末尾スラッシュ、大文字小文字、
   クエリ付き、ホスト名の後ろに別ドメインを足したもの)
4. 認可サーバーが error を返した
正常系は RFC 7636 付録Bのベクタ(S256)で固定して。
テストが実装なしで失敗することを実行して見せてから、実装に進んで。

「失敗することを実行して見せて」の一文が要です。実装が無い状態で node --test を回すと、oauth-client.mjs が見つからず ERR_MODULE_NOT_FOUND で止まります。テストが何も検証していない状態で緑になる事故を、ここで避けられます。

テストは異常系と仕様のベクタで固める

下のテストはNode.js 20.18の標準 node:test で書いています。外部ライブラリは使いません。

import test from 'node:test';
import assert from 'node:assert/strict';
import {
  createCodeVerifier, createCodeChallenge, startAuthorization,
  validateCallback, buildTokenRequest, assertRegisteredRedirectUri,
} from './oauth-client.mjs';
 
const cfg = {
  authorizeEndpoint: 'https://auth.example/authorize',
  clientId: 'demo-client',
  redirectUri: 'http://127.0.0.1:8080/callback',
};
 
test('RFC 7636 付録Bのベクタと S256 が一致する', () => {
  assert.equal(
    createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
    'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
 
test('state が一致しないコールバックは拒否する', () => {
  const session = { state: 'abc', codeVerifier: 'v' };
  assert.throws(() => validateCallback({ code: 'c', state: 'zzz' }, session), /state/);
  assert.throws(() => validateCallback({ code: 'c' }, session), /state/);
});
 
test('code_verifier が無いとトークン要求を組み立てない', () => {
  assert.throws(() => buildTokenRequest(
    { ...cfg, tokenEndpoint: 'https://auth.example/token' },
    { code: 'c' }, { state: 'abc' }), /code_verifier/);
});

本文に載せたのは抜粋で、全文は次の節の折りたたみにあります。全体では9件のテストを置きました。残りは、code_verifier が43文字以上128文字以下の使用可能文字だけで構成されること、認可URLに code_challenge_method=S256 と state が入ること、使用済みのstateを再利用できないこと、error 応答を拒否すること、トークン要求の redirect_uri が認可要求と同じ値になること、redirect_uri を完全一致で比べることです。

redirect_uri のテストは、攻撃者が試す形を並べるのがこつです。

test('登録外の redirect_uri は完全一致でなければ拒否する', () => {
  const registered = [cfg.redirectUri];
  assertRegisteredRedirectUri(cfg.redirectUri, registered);
  for (const bad of [
    'http://127.0.0.1:8080/callback/',
    'http://127.0.0.1:8080/callback?next=https://evil.example',
    'http://127.0.0.1.evil.example/callback',
    'http://127.0.0.1:8080/Callback',
  ]) {
    assert.throws(() => assertRegisteredRedirectUri(bad, registered), /redirect_uri/);
  }
});

末尾スラッシュ、クエリの付加、ホスト名への別ドメインの連結、大文字小文字の違いは、前方一致や正規表現で書かれた実装が通してしまう典型です。Claudeに実装を任せるとき、この4本があれば「だいたい合っている」実装は落ちます。

PKCEのベクタをRFCから取る理由もあります。code_verifier の dBjf... からS256で E9Mel... が出るという組は、RFC 7636付録Bに載っている値です。ハッシュ関数やBase64URLの扱いをClaudeが取り違えても、この1本で検出できます。

実装してテストを通す

テストが失敗することを確認したら、実装に進ませます。次のコードは、9件のテストが通る最小の形です。

import { createHash, randomBytes } from 'node:crypto';
 
export const createCodeVerifier = () => randomBytes(32).toString('base64url');
 
export const createCodeChallenge = (verifier) =>
  createHash('sha256').update(verifier, 'ascii').digest('base64url');
 
export function assertRegisteredRedirectUri(uri, registered) {
  if (!registered.includes(uri)) throw new Error(`redirect_uri が登録値と一致しません: ${uri}`);
}
 
export function validateCallback(params, session) {
  const expected = session.state;
  session.state = undefined; // 一度きり
  if (!expected || params.state !== expected) throw new Error('state が一致しません');
  if (params.error) throw new Error(`認可エラー: ${params.error}`);
  if (!params.code) throw new Error('code がありません');
  return params.code;
}

randomBytes(32) をBase64URLにすると43文字になります。RFC 7636が推奨する「32オクテットの乱数列をBase64URL化して43文字のverifierを得る」手順そのままです。上のコードは抜粋です。startAuthorization(認可URLの組み立て)と buildTokenRequest(トークン要求の本文づくり)を含む実装の全文は、次の折りたたみに載せました。

テスト9件と実装の全文

oauth-client.test.mjs の全文です。

import test from 'node:test';
import assert from 'node:assert/strict';
import {
  createCodeVerifier, createCodeChallenge, startAuthorization,
  validateCallback, buildTokenRequest, assertRegisteredRedirectUri,
} from './oauth-client.mjs';
 
const cfg = {
  authorizeEndpoint: 'https://auth.example/authorize',
  tokenEndpoint: 'https://auth.example/token',
  clientId: 'demo-client',
  redirectUri: 'http://127.0.0.1:8080/callback',
};
 
test('RFC 7636 付録Bのベクタと S256 が一致する', () => {
  assert.equal(
    createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'),
    'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
 
test('code_verifier は43〜128文字の使用可能文字だけで作る', () => {
  const v = createCodeVerifier();
  assert.match(v, /^[A-Za-z0-9\-._~]{43,128}$/);
});
 
test('認可URLに S256 と state が入る', () => {
  const { url, session } = startAuthorization(cfg);
  const q = new URL(url).searchParams;
  assert.equal(q.get('code_challenge_method'), 'S256');
  assert.equal(q.get('state'), session.state);
  assert.equal(q.get('redirect_uri'), cfg.redirectUri);
});
 
test('state が一致しないコールバックは拒否する', () => {
  const session = { state: 'abc', codeVerifier: 'v' };
  assert.throws(() => validateCallback({ code: 'c', state: 'zzz' }, session), /state/);
  assert.throws(() => validateCallback({ code: 'c' }, session), /state/);
});
 
test('使用済みの state は再利用できない', () => {
  const session = { state: 'abc', codeVerifier: 'v' };
  assert.equal(validateCallback({ code: 'c', state: 'abc' }, session), 'c');
  assert.throws(() => validateCallback({ code: 'c', state: 'abc' }, session), /state/);
});
 
test('error 応答は code が付いていても拒否する', () => {
  const session = { state: 'abc', codeVerifier: 'v' };
  assert.throws(() => validateCallback(
    { error: 'access_denied', code: 'c', state: 'abc' }, session), /認可エラー/);
});
 
test('code_verifier が無いとトークン要求を組み立てない', () => {
  assert.throws(() => buildTokenRequest(
    cfg, { code: 'c' }, { state: 'abc' }), /code_verifier/);
});
 
test('トークン要求は redirect_uri を引き継ぎ、client_secret は env からだけ入る', () => {
  const { session } = startAuthorization(cfg);
  const { body } = buildTokenRequest(cfg, { code: 'c' }, session);
  assert.equal(body.get('redirect_uri'), cfg.redirectUri);
  assert.equal(body.get('code_verifier'), session.codeVerifier);
  assert.equal(body.has('client_secret'), false);
  const withSecret = buildTokenRequest(
    cfg, { code: 'c' }, session, { OAUTH_CLIENT_SECRET: 's3' });
  assert.equal(withSecret.body.get('client_secret'), 's3');
});
 
test('登録外の redirect_uri は完全一致でなければ拒否する', () => {
  const registered = [cfg.redirectUri];
  assertRegisteredRedirectUri(cfg.redirectUri, registered);
  for (const bad of [
    'http://127.0.0.1:8080/callback/',
    'http://127.0.0.1:8080/callback?next=https://evil.example',
    'http://127.0.0.1.evil.example/callback',
    'http://127.0.0.1:8080/Callback',
  ]) {
    assert.throws(() => assertRegisteredRedirectUri(bad, registered), /redirect_uri/);
  }
});

oauth-client.mjs の全文です。buildTokenRequest の第4引数 env から OAUTH_CLIENT_SECRET を読む作りで、テストでは { OAUTH_CLIENT_SECRET: 's3' } というダミー値を渡します。

import { createHash, randomBytes } from 'node:crypto';
 
export const createCodeVerifier = () => randomBytes(32).toString('base64url');
 
export const createCodeChallenge = (verifier) =>
  createHash('sha256').update(verifier, 'ascii').digest('base64url');
 
export function assertRegisteredRedirectUri(uri, registered) {
  if (!registered.includes(uri)) {
    throw new Error(`redirect_uri が登録値と一致しません: ${uri}`);
  }
}
 
export function startAuthorization(cfg) {
  const state = randomBytes(16).toString('base64url');
  const codeVerifier = createCodeVerifier();
  const url = new URL(cfg.authorizeEndpoint);
  url.search = new URLSearchParams({
    response_type: 'code',
    client_id: cfg.clientId,
    redirect_uri: cfg.redirectUri,
    state,
    code_challenge: createCodeChallenge(codeVerifier),
    code_challenge_method: 'S256',
  }).toString();
  return { url: url.toString(), session: { state, codeVerifier } };
}
 
export function validateCallback(params, session) {
  const expected = session.state;
  session.state = undefined; // 一度きり
  if (!expected || params.state !== expected) {
    throw new Error('state が一致しません');
  }
  if (params.error) throw new Error(`認可エラー: ${params.error}`);
  if (!params.code) throw new Error('code がありません');
  return params.code;
}
 
export function buildTokenRequest(cfg, params, session, env = {}) {
  if (!session.codeVerifier) throw new Error('code_verifier がありません');
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code: params.code,
    redirect_uri: cfg.redirectUri,
    client_id: cfg.clientId,
    code_verifier: session.codeVerifier,
  });
  if (env.OAUTH_CLIENT_SECRET) {
    body.set('client_secret', env.OAUTH_CLIENT_SECRET);
  }
  return { url: cfg.tokenEndpoint, body };
}
node --test oauth-client.test.mjs

この構成で実行すると、9件すべてが通ります。ここまでが検証ループの1周です。Claude Codeに「通るまで直して」と頼む場合も、通ったあとの確認として「実行したコマンドと出力を見せて」と添えると、報告だけが先行するのを避けられます。ベストプラクティスも、完了報告そのものより、実行したコマンドとその出力を求めるよう勧めています。

テストを自動で回し続けたい場合は、Stop hookで node --test を終了条件にできます。仕組みはagent hookでテストが通るまで止めない手順に書いてあります。サーバー側のコールバックを持つ実装なら、HTTP層まで含めた統合テストで検証する構成と組み合わせると、リダイレクトの往復も固定できます。

stateとPKCEはどちらを残すか

「PKCEがあるならstateは要らないのでは」という疑問が出ます。RFC 9700は、認可サーバーがPKCEに対応していることをクライアントが確認できている場合に限り、PKCEによるCSRF対策に頼ってよいとしています。認可サーバーがPKCEに対応しない場合は、stateかnonceをCSRF対策に使わなければなりません。

実装を頼む時点で、接続先がPKCE対応かを確認できていないなら、両方を入れておくと確実です。上の実装は、PKCEとstateを併用しています。RFC 9700はまた、stateにアプリケーションの状態を載せるなら、改ざんや差し替えから保護する必要があると述べています。ログイン後の戻り先URLをstateに詰める実装をClaudeが提案してきたときは、この点をテストに足して確かめます。

client_secretを会話に出さない渡し方

機密クライアントでは、トークン要求に client_secret が入ります。この値を会話に出さないための設定は、ベストプラクティスの「例外を許さないルール」の考え方に沿います。CLAUDE.mdの記述は指針にすぎず、Claudeが当てはまらないと判断する余地があります。必ず守らせたいものは、設定の側に置きます。

{
  "permissions": {
    "allow": ["Bash(node --test *)"],
    "deny": ["Read(./.env)", "Read(./.env.local)", "Read(./secrets/**)"]
  }
}

deny ルールは、autoや bypassPermissions を含むすべての権限モードで効きます。Read(./.env) はReadツールだけでなく、cat .env のようなファイル読み取りコマンドも止めます。権限ルールはdeny、ask、allowの順に評価され、先にdenyに当たった呼び出しが拒否されます。allowに同じ対象を足しても例外にはなりません。

アプリは process.env.OAUTH_CLIENT_SECRET から値を読みます。テストでは、buildTokenRequest の第4引数に環境変数のオブジェクトを渡せる作りにしてあるので、実物のsecretは要りません。ダミー文字列 s3 を渡して client_secret が本文に入ること、渡さなければ入らないことだけを検証します(全文の折りたたみにある、トークン要求のテストです)。Claudeに実行させるのはテストまでにして、実際のsecretを使う起動は人間がターミナルで行う分担にすると、secretが会話に入る経路を作らずに済みます。

落とし穴

deny ルールは「値を使うスクリプト」までは止めない

Read のdenyは、ファイルを読む組み込みツールと、Claude Codeが認識するファイル読み取りコマンドに効きます。ファイルを自分で開くスクリプトには効きません。たとえばNodeのスクリプトが .env を開いて値を表示すれば、ルールの外です。OS側で閉じたい場合は、/sandbox でサンドボックスを有効にします。サンドボックスは Read のdenyルールも取り込みます。また、Claudeを起動したシェルにsecretを export していると、Bashから環境変数として見えるため、secretを渡すのは起動コマンドの行内だけにします。

.env を読めなくても、secretが漏れる別経路はあります。ログ出力、エラーメッセージ、テストのスナップショットです。CLAUDE.mdの「ログに値を書かない」を、grep でsecretらしき文字列が出力に無いことを確かめるテストで補強する方法もあります。

このテストが保証しない範囲

9件のテストが緑でも、OAuthの実装が安全になったわけではありません。保証されるのはクライアント側の入力検証と、PKCEの計算の正しさだけです。

  • redirect_uri の完全一致比較は、本来は認可サーバーの責務です。クライアントのテストが確かめるのは、自分が送る値が登録値と一致していることです。
  • 認可サーバーが code_verifier を検証し、不一致なら invalid_grant を返すかどうかは、サーバー実装を持たないと確かめられません。
  • トークンの保存先、リフレッシュ、失効は別の設計です。
  • ポート番号つきのループバックURIなど、ネイティブアプリ特有の例外はRFC 9700に規定があり、今回の完全一致の例とは扱いが異なります。

Claude Code自身のログインで同じ症状に当たったなら、実装の話ではありません。たとえばログインがループするstate消失の原因のように、サインインフロー側の不具合として切り分けます。NestJSのような特定のフレームワークで組むなら、NestJSアプリのe2eテストによる検証手順が使えます。

Claudeに認可サーバー側まで実装させた公開事例はCloudflareのOAuthライブラリのコミット履歴を読む記事にあります。クライアントとは違い、人間が設計とレビューを持ち続けた点が参考になります。

まとめ

OAuthクライアントをClaude Codeに任せるときの要点は、拒否条件を先にテストへ落とすこと、仕様のベクタで計算を固定すること、secretを設定で守ることの3つです。テストは「通すためのもの」ではなく、Claudeの実装が仕様から外れたときに赤くなる見張りとして働きます。

実際の案件では、接続先の認可サーバーが対応するPKCEのメソッド、state に載せたい情報、機密クライアントか公開クライアントかで、テストの中身が変わります。まずはRFC 9700の2章を読み、自分のクライアントに当てはまる項目をテストへ1本ずつ落とすところから始めるのが確実です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. code.claude.com/docs/en/permissions
  3. datatracker.ietf.org/doc/html/rfc7636
  4. rfc-editor.org/rfc/rfc6749
  5. rfc-editor.org/rfc/rfc9700
この記事を共有:XはてブLinkedIn