RustではじめるOIDC

設計判断と知識の一覧

この章でわかること:

  • アプリケーションの種類から、使うフローとクライアントの構成を決める手順
  • クライアント認証の方式とトークンの形式を選ぶ基準
  • RP・OP・リソースサーバのそれぞれが実装しなければならない検査の一覧
  • 本書の要素をすべて組み合わせた簡易実装での、正常な流れと7つの攻撃の結果
  • なぜ体系に必須か: 個々の仕様の知識は、どの選択肢をどの理由で選ぶかの判断に結びついて、はじめて設計に使えるようになるため

判断の順序

本書の27章までは、OAuth 2.0 と OpenID Connect の要素を1つずつ説明してきました。 実際のシステムを設計するときは、これらの要素を選び、組み合わせる必要があります。 この章では、その選択を次の順序で行います。

1. 何を実現するか
ログイン / API の認可 / 両方

2. クライアントはどこで動くか
サーバ / ブラウザ / ネイティブ / 入力の限られた端末 / 利用者なし

3. フローを選ぶ

4. クライアント認証を選ぶ

5. トークンの形式と寿命を決める

6. 送信者制約とリクエストの保護が必要かを決める

7. 役割ごとの検査を実装する

この図の1つ目の判断は、ログイン(認証)と API の認可(アクセストークン)のどちらが目的かです。 6章で説明したとおり、ログインには OpenID Connect の ID トークンを使い、OAuth 2.0 のアクセストークンだけでログインを実装してはなりません。 アクセストークンには、「誰がログインしたか」をクライアントに伝える役割はありません。 別のクライアント向けに発行されたトークンを受け入れると、トークン置換攻撃が成立します。 API の認可だけが目的であれば OpenID Connect は必須ではありませんが、 認可サーバと OP を同じサービスで兼ねる構成が多いため、両方を同時に扱う設計がよく現れます。

フローを選ぶ

2つ目と3つ目の判断は、クライアントが動く場所からほぼ一意に決まります。

クライアント フロー クライアントの種類 根拠
サーバ側で HTML を生成する Web アプリケーション 認可コードフロー + PKCE 機密クライアント 3章、9章、11章
SPA(ブラウザ上の JavaScript) BFF を置き、BFF が認可コードフロー + PKCE を行う BFF が機密クライアント 24章(RFC 10017)
ネイティブアプリ・デスクトップアプリ 外部ユーザーエージェントでの認可コードフロー + PKCE パブリッククライアント 24章(RFC 8252)
テレビ、CLI など入力の限られた端末 デバイス認可グラント パブリッククライアントが多い 20章(RFC 8628)
利用者のいないサーバ間の通信 クライアントクレデンシャルグラント 機密クライアント 20章
受け取ったトークンで別のサービスを呼ぶサービス トークン交換 機密クライアント 20章(RFC 8693)
利用者の別の端末で認証する(コールセンター、店頭の端末) CIBA 機密クライアント 20章

表に現れないフローは、新しい設計で選ぶ理由がないものです。 インプリシットフローはアクセストークンをブラウザの URL に出すため、 RFC 9700 は使うべきではない(SHOULD NOT)と定めています(19章)。 リソースオーナーパスワードクレデンシャルグラントについては、RFC 9700 は使ってはならない(MUST NOT)と定めています (20章)。 ハイブリッドフローは、ID トークンをフロントチャネルで先に受け取る必要がある場合 (FAPI 1.0 の古い構成など)に限られ、通常は認可コードフローで足ります。

SPA の行で BFF を選んでいるのは、RFC 10017 が挙げる3つのアーキテクチャのうち、 トークンを JavaScript から読める場所に置かずに済むのが BFF だけであるためです。 XSS が起きた場合、ブラウザ内の OAuth クライアントではトークンそのものが読み出されます。 BFF の構成で攻撃者が得られるのは、セッション Cookie 付きでリクエストを送る能力だけであり、 トークンを持ち出して別の場所から使うことはできません(24章)。

クライアント認証を選ぶ

機密クライアントは、トークンエンドポイントで自分を認証します(13章)。

方式 秘密の値 漏洩時の影響 選ぶ場面
client_secret_basic / client_secret_post 認可サーバと共有するシークレット 認可サーバがシークレットを復元できる形で保存していると、保存値の漏洩でなりすましが可能になる 一般的な Web アプリケーション。記号を含むシークレットでは basic の符号化の実装差に注意する
client_secret_jwt 共有シークレット(HMAC の鍵) 認可サーバは MAC を検証するために鍵そのものを保存する必要があり、保存値の漏洩でなりすましが可能になる 選ぶ理由は少ない
private_key_jwt クライアントだけが持つ秘密鍵 認可サーバは公開鍵しか持たない 高いセキュリティが必要な場合。FAPI 2.0 で使える方式の1つ
tls_client_auth / self_signed_tls_client_auth クライアント証明書の秘密鍵 認可サーバは証明書(公開鍵)しか持たない mTLS を終端できる構成。証明書束縛トークンと組み合わせられる

方式の違いは、認可サーバ側に保存される値がクライアントのなりすましに使えるかどうかです。 client_secret_basic と client_secret_post では、認可サーバはシークレットのハッシュ値だけを 保存して照合できます(RFC 6819 5.1.4.1.3節はシークレットを平文で保存しないことを求めています)。 この場合、保存値が漏洩しても、攻撃者はハッシュ値から元のシークレットを得なければなりません。 client_secret_jwt では、認可サーバは HMAC を検証するために鍵そのものを持つ必要があるため、 この対策がとれません。private_key_jwt と mTLS では、認可サーバは公開鍵しか持ちません。 このため認可サーバのデータベースが漏洩しても、攻撃者はクライアントになりすませません。

パブリッククライアントには認証の手段がありません。このため、パブリッククライアントの 安全性は PKCE(9章)と、リダイレクト URI の完全一致 (22章)と、リフレッシュトークンのローテーションまたは送信者制約 (15章、16章)によって確保します。

トークンの形式と寿命を決める

アクセストークンの形式には、リソースサーバが自分で検証する JWT (14章の RFC 9068)と、認可サーバに問い合わせる 不透明トークン(RFC 7662 のイントロスペクション)があります。

観点 JWT アクセストークン 不透明トークン + イントロスペクション
リソースサーバの検証 署名と各クレームをリソースサーバ自身が検査する リクエストごとに(またはキャッシュして)認可サーバに問い合わせる
失効の反映 有効期限まで反映されない 問い合わせの時点で反映される(キャッシュの期間だけ遅れる)
認可サーバへの負荷 JWKS の取得だけ リクエスト数に比例する
内容の秘匿 ペイロードはクライアントにも読める クライアントには読めない

選択の基準は、失効をどれだけ早く反映させる必要があるかです。 JWT アクセストークンを選ぶ場合は寿命を短く(数分程度)し、長く使う権限は リフレッシュトークンで更新します。リフレッシュトークンは認可サーバだけが検証するので、 失効を即座に反映できます。

どちらの形式でも、ID トークンをアクセストークンの代わりに API に送ってはなりません。 ID トークンの宛先(aud)は RP であり、リソースサーバではありません (8章)。JWT アクセストークンには typ に at+jwt を付け、 リソースサーバは typ を検査して ID トークンとの取り違えを防ぎます (14章、23章)。

送信者制約とリクエストの保護

ここまでの選択で、一般的な Web アプリケーションの安全性の要件は満たされます。 次の2つは、扱うデータの重要性に応じて追加する対策です。

  • 送信者制約: アクセストークンが漏洩した場合に、攻撃者がそのトークンを使えないようにします。 アプリケーション層で行う DPoP と、TLS 層で行う mTLS があります (16章)。パブリッククライアントのリフレッシュトークンを 保護する手段としても有効です
  • リクエストの保護: 認可リクエストの改ざんと漏洩を防ぎます。PAR は認可リクエストを バックチャネルで事前に送り、JAR はリクエストに署名します。認可レスポンスの iss パラメータは ミックスアップ攻撃を防ぎます(17章)

金融 API のように、漏洩や改ざんの影響が大きい場合は、これらを個別に選ぶ代わりに FAPI 2.0 Security Profile に従います。FAPI 2.0 は PAR、PKCE、送信者制約、 公開鍵によるクライアント認証を組み合わせて必須にしたプロファイルであり、 適合性テストで実装を検査できます(25章)。

RP のチェックリスト

7つ目の判断は、役割ごとの検査の実装です。チェックリストの各項目は、 省略したときに成立する攻撃と、それを説明した章に対応しています。

認可リクエストを送るとき

  • state を推測できない値で生成し、ブラウザのセッションに結びつけて保存しています(9章)
  • nonce を生成して保存し、ID トークンの nonce と照合しています(9章)
  • PKCE の code_verifier を CSPRNG で生成し、S256 で code_challenge を送っています(9章)
  • OP の設定値は Discovery から取得し、メタデータの issuer が設定値と完全一致することを確かめています(10章)

認可レスポンスを受け取るとき

  • state を照合し、照合後はセッションから削除しています(再送の防止)(11章)
  • 認可レスポンスに iss があれば、認可リクエストを送った OP の issuer と単純な文字列比較で照合しています。メタデータで authorization_response_iss_parameter_supported を宣言している OP からの応答に iss がなければ拒否しています(RFC 9207 2.4節)。iss に対応しない OP とも接続する場合は、OP ごとにリダイレクト URI を分けるなど、別のミックスアップ対策をとっています(17章)
  • エラー応答(error)を、ログイン成功とは別の経路で処理しています(3章)

トークンを受け取るとき

  • ID トークンの署名を、Discovery で得た JWKS の鍵で検証しています。alg は許可リストで固定しています(8章)
  • iss、aud(と複数の場合の azp)、exp、iat を検査しています(8章)
  • at_hash がある場合は、受け取ったアクセストークンと照合しています(8章)
  • ユーザーは iss と sub の組で識別し、email だけで既存のアカウントに結びつけていません(18章)
  • ログイン後にセッション ID を再発行しています(セッション固定攻撃の防止)(11章)

ログイン後

  • セッション Cookie に Secure、HttpOnly、SameSite を付けています。可能なら __Host- 接頭辞を使っています(1章、24章)
  • トークンをログに出力していません(26章)
  • ログアウトでは自分のセッションを削除し、必要に応じて OP にも RP-Initiated Logout で知らせています。Back-Channel Logout を受け取る場合はログアウトトークンを検証しています(21章)
  • 未知の kid を受け取ったら JWKS を取り直し、取り直しの頻度を制限しています(26章)

OP のチェックリスト

認可エンドポイント

  • client_id と redirect_uri を検証し、登録値と完全一致しない場合はリダイレクトせずにエラーを表示しています。ネイティブアプリのループバックリダイレクト(http://127.0.0.1:{port}/...)に限り、ポート番号以外の完全一致で照合しています(12章、22章、24章)
  • パラメータの重複を拒否しています(12章)
  • パブリッククライアントには PKCE を必須にし、plain を受け付けていません(9章)
  • ログイン画面と同意画面に、フレームへの埋め込みを禁止するヘッダを付けています(クリックジャッキングの防止)(12章、22章)
  • 認可レスポンスに iss を付けています(17章)

認可コード

  • 認可コードは推測できない値で、寿命は短く(1分程度)、1回だけ使えます(12章)
  • 認可コードを client_id、redirect_uri、code_challenge、nonce に結びつけて保存しています(12章)
  • 使用済みのコードが再度使われたら、そのコードで発行したトークンを失効させています(RFC 6749 4.1.2節の SHOULD)(12章)

トークンエンドポイント

  • クライアント認証を、定数時間比較などで正しく行っています(5章、13章)
  • 認可コードの有効期限と、保存した client_id・redirect_uri との一致を確かめています(12章)
  • code_verifier から計算した値を保存した code_challenge と照合しています。code_challenge なしで発行したコードの交換に code_verifier が付いていた場合は拒否しています(PKCE ダウングレード攻撃の防止。RFC 9700 4.8.2節)(9章)
  • レスポンスに Cache-Control: no-store を付けています(4章)
  • リフレッシュトークンは、パブリッククライアントにはローテーションか送信者制約を適用しています(15章)

鍵と公開情報

  • 署名鍵のローテーションを「公開 → 待機 → 切り替え → 待機 → 削除」の順で行っています(26章)
  • Discovery の issuer と ID トークンの iss が完全に同じ文字列です(10章)

リソースサーバのチェックリスト

  • Bearer トークンは Authorization: Bearer ヘッダで受け付け、クエリパラメータのトークンは受け付けていません(4章)
  • JWT アクセストークンでは typ(at+jwt)、署名、iss、aud、exp を検査しています(14章)
  • aud に自分の識別子が含まれないトークンを拒否しています(14章)
  • スコープの不足は 403 と insufficient_scope、トークンの不正は 401 と invalid_token で返しています(4章)
  • DPoP を使う場合は、Authorization: DPoP ヘッダのトークンと DPoP ヘッダの証明を要求し、証明の署名、htm、htu、iat、jti、ath と、トークンの cnf.jkt を照合しています。DPoP に束縛されたトークンが Bearer として送られてきた場合は拒否しています(RFC 9449 7節)(16章)
  • より強い認証が必要な操作では、RFC 9470 の insufficient_user_authentication で再認証を求めています(18章)

実験: 本書の要素を1つのプロセスで動かす

最後に、本書の要素を1つのプロセスの中に組み合わせた簡易実装を実行します。 OP は ES256 の鍵を生成して ID トークンと JWT アクセストークンに署名し、 RP は state・nonce・PKCE を使ってログインし、リソースサーバは JWT アクセストークンを検証します。 各部分は、3章(認可コードフロー)、5章(署名)、7章(JWS)、8章(ID トークンの検証)、 9章(state・nonce・PKCE)、12章(認可コードの束縛)、14章(JWT アクセストークン)、 17章(iss パラメータ)の実装を短くしたものです。

OP・RP・リソースサーバを1つのプロセスで動かし、7つの攻撃を試す
// 本書の要素を1つのプロセスに統合した OP・RP・リソースサーバの簡易実装
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{self, EcdsaKeyPair, KeyPair};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use subtle::ConstantTimeEq;

const ISSUER: &str = "https://op.example";
const RS_ID: &str = "https://api.example";
const CLIENT_ID: &str = "demo-rp";
const CLIENT_SECRET: &str = "dummy-secret";
const REDIRECT_URI: &str = "https://rp.example/callback";

fn random_b64(rng: &SystemRandom, n: usize) -> String {
    let mut buf = vec![0u8; n];
    rng.fill(&mut buf).unwrap();
    B64.encode(buf)
}

fn s256(v: &str) -> String {
    B64.encode(Sha256::digest(v.as_bytes()))
}

fn sign_jws(key: &EcdsaKeyPair, rng: &SystemRandom, header: &Value, payload: &Value) -> String {
    let input = format!(
        "{}.{}",
        B64.encode(header.to_string()),
        B64.encode(payload.to_string())
    );
    let sig = key.sign(rng, input.as_bytes()).unwrap();
    format!("{input}.{}", B64.encode(sig.as_ref()))
}

// 署名を検証してからヘッダとペイロードを返す。alg は ES256 に固定する
fn verify_jws(jwt: &str, jwks: &Value, typ: &str) -> Result<Value, String> {
    let parts: Vec<&str> = jwt.split('.').collect();
    if parts.len() != 3 {
        return Err("JWS の形式が不正".into());
    }
    let header: Value = serde_json::from_slice(&B64.decode(parts[0]).map_err(|_| "ヘッダの復号に失敗")?)
        .map_err(|_| "ヘッダが JSON でない")?;
    if header["alg"] != "ES256" {
        return Err(format!("許可していない alg: {}", header["alg"]));
    }
    if header["typ"] != typ {
        return Err(format!("typ が {typ} でない: {}", header["typ"]));
    }
    let jwk = jwks["keys"]
        .as_array()
        .unwrap()
        .iter()
        .find(|k| k["kid"] == header["kid"])
        .ok_or("JWKS に kid がない")?;
    let mut point = vec![0x04];
    point.extend(B64.decode(jwk["x"].as_str().unwrap()).unwrap());
    point.extend(B64.decode(jwk["y"].as_str().unwrap()).unwrap());
    let sig = B64.decode(parts[2]).map_err(|_| "署名の復号に失敗")?;
    signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_FIXED, &point)
        .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &sig)
        .map_err(|_| "署名の検証に失敗")?;
    serde_json::from_slice(&B64.decode(parts[1]).unwrap()).map_err(|_| "ペイロードが JSON でない".into())
}

struct CodeRecord {
    client_id: String,
    redirect_uri: String,
    code_challenge: String,
    nonce: String,
    expires_at: u64,
}

struct Op {
    rng: SystemRandom,
    key: EcdsaKeyPair,
    kid: String,
    codes: HashMap<String, CodeRecord>,
}

impl Op {
    fn new() -> Op {
        let rng = SystemRandom::new();
        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
        let key = EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
        Op { rng, key, kid: "k1".into(), codes: HashMap::new() }
    }

    fn jwks(&self) -> Value {
        let p = self.key.public_key().as_ref();
        json!({"keys": [{"kty": "EC", "crv": "P-256", "kid": self.kid, "use": "sig", "alg": "ES256",
            "x": B64.encode(&p[1..33]), "y": B64.encode(&p[33..65])}]})
    }

    // 認可エンドポイント: client_id と redirect_uri の照合に失敗したらリダイレクトしない
    fn authorize(&mut self, q: &HashMap<String, String>, now: u64) -> Result<String, String> {
        if q.get("client_id").map(String::as_str) != Some(CLIENT_ID)
            || q.get("redirect_uri").map(String::as_str) != Some(REDIRECT_URI)
        {
            return Err("エラー画面を表示(リダイレクトしない)".into());
        }
        let state = q.get("state").cloned().unwrap_or_default();
        // 値はクエリとして符号化して付ける(state に & が入っていても別のパラメータにならない)
        let respond = |pairs: &[(&str, &str)]| {
            let mut url = url::Url::parse(REDIRECT_URI).unwrap();
            url.query_pairs_mut().extend_pairs(pairs).append_pair("state", &state).append_pair("iss", ISSUER);
            url.to_string()
        };
        let fail = |e: &str| Ok(respond(&[("error", e)]));
        if q.get("response_type").map(String::as_str) != Some("code") {
            return fail("unsupported_response_type");
        }
        if !q.get("scope").is_some_and(|s| s.split(' ').any(|t| t == "openid")) {
            return fail("invalid_scope");
        }
        if q.get("code_challenge_method").map(String::as_str) != Some("S256") || !q.contains_key("code_challenge") {
            return fail("invalid_request");
        }
        let code = random_b64(&self.rng, 32);
        self.codes.insert(code.clone(), CodeRecord {
            client_id: CLIENT_ID.into(),
            redirect_uri: REDIRECT_URI.into(),
            code_challenge: q["code_challenge"].clone(),
            nonce: q.get("nonce").cloned().unwrap_or_default(),
            expires_at: now + 60,
        });
        Ok(respond(&[("code", &code)]))
    }

    // トークンエンドポイント: クライアント認証 → コードの取り出し(1回限り) → 束縛の照合 → PKCE
    fn token(&mut self, client_id: &str, secret: &str, form: &HashMap<&str, &str>, now: u64) -> Result<Value, String> {
        let secret_ok: bool = secret.as_bytes().ct_eq(CLIENT_SECRET.as_bytes()).into();
        if client_id != CLIENT_ID || !secret_ok {
            return Err("401 invalid_client".into());
        }
        match form.get("grant_type") {
            None => return Err("400 invalid_request (grant_type がない)".into()),
            Some(&"authorization_code") => {}
            Some(_) => return Err("400 unsupported_grant_type".into()),
        }
        let rec = self.codes.remove(form["code"]).ok_or("400 invalid_grant (未知または使用済みのコード)")?;
        if rec.expires_at <= now || rec.client_id != client_id || rec.redirect_uri != form["redirect_uri"] {
            return Err("400 invalid_grant (期限切れまたは束縛の不一致)".into());
        }
        if s256(form.get("code_verifier").unwrap_or(&"")) != rec.code_challenge {
            return Err("400 invalid_grant (PKCE の検証に失敗)".into());
        }
        let at = sign_jws(&self.key, &self.rng, &json!({"alg": "ES256", "typ": "at+jwt", "kid": self.kid}),
            &json!({"iss": ISSUER, "sub": "user-0001", "aud": RS_ID, "client_id": CLIENT_ID,
                "scope": "openid api.read", "iat": now, "exp": now + 300, "jti": random_b64(&self.rng, 16)}));
        let at_hash = B64.encode(&Sha256::digest(at.as_bytes())[..16]);
        let id = sign_jws(&self.key, &self.rng, &json!({"alg": "ES256", "typ": "JWT", "kid": self.kid}),
            &json!({"iss": ISSUER, "sub": "user-0001", "aud": CLIENT_ID, "iat": now, "exp": now + 300,
                "nonce": rec.nonce, "at_hash": at_hash}));
        Ok(json!({"access_token": at, "token_type": "Bearer", "expires_in": 300, "id_token": id}))
    }
}

struct Rp {
    // 照合したら取り出して消す(同じ認可レスポンスの再送を拒否するため)
    state: Option<String>,
    nonce: String,
    verifier: String,
}

impl Rp {
    fn login(rng: &SystemRandom) -> (Rp, HashMap<String, String>) {
        let rp = Rp { state: Some(random_b64(rng, 16)), nonce: random_b64(rng, 16), verifier: random_b64(rng, 32) };
        let q = [
            ("response_type", "code"), ("client_id", CLIENT_ID), ("redirect_uri", REDIRECT_URI),
            ("scope", "openid api.read"), ("state", rp.state.as_deref().unwrap()), ("nonce", &rp.nonce),
            ("code_challenge", &s256(&rp.verifier)), ("code_challenge_method", "S256"),
        ]
        .iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect();
        (rp, q)
    }

    fn parse_callback(&mut self, location: &str) -> Result<String, String> {
        let url = url::Url::parse(location).map_err(|_| "URL が不正")?;
        let p: HashMap<_, _> = url.query_pairs().into_owned().collect();
        let Some(expected) = self.state.take() else {
            return Err("処理中のログインがない(再送の疑い)".into());
        };
        if p.get("state") != Some(&expected) {
            return Err("state が一致しない(CSRF の疑い)".into());
        }
        if p.get("iss").map(String::as_str) != Some(ISSUER) {
            return Err("iss が一致しない(ミックスアップの疑い)".into());
        }
        match (p.get("code"), p.get("error")) {
            (Some(code), None) => Ok(code.clone()),
            (None, Some(e)) => Err(format!("エラー応答: {e}")),
            _ => Err("code と error の組み合わせが不正".into()),
        }
    }

    fn validate_id_token(&self, res: &Value, jwks: &Value, now: u64) -> Result<String, String> {
        let c = verify_jws(res["id_token"].as_str().unwrap(), jwks, "JWT")?;
        let checks = [
            (c["iss"] == ISSUER, "iss"),
            (c["aud"] == CLIENT_ID, "aud"),
            (c["exp"].as_u64().unwrap_or(0) > now, "exp"),
            (c["nonce"] == self.nonce.as_str(), "nonce"),
        ];
        if let Some((_, name)) = checks.iter().find(|(ok, _)| !ok) {
            return Err(format!("ID トークンの {name} が不正"));
        }
        let at = res["access_token"].as_str().unwrap();
        if c["at_hash"] != B64.encode(&Sha256::digest(at.as_bytes())[..16]).as_str() {
            return Err("at_hash が一致しない".into());
        }
        Ok(c["sub"].as_str().unwrap().into())
    }
}

// リソースサーバ: typ=at+jwt、署名、iss、aud、exp、scope の順に検査する
fn resource_server(authz: &str, jwks: &Value, now: u64) -> String {
    let Some(token) = authz.strip_prefix("Bearer ") else {
        return "401 (Bearer トークンがない)".into();
    };
    match verify_jws(token, jwks, "at+jwt") {
        Err(e) => format!("401 invalid_token ({e})"),
        Ok(c) if c["iss"] != ISSUER || c["aud"] != RS_ID => "401 invalid_token (iss または aud が不正)".into(),
        Ok(c) if c["exp"].as_u64().unwrap_or(0) <= now => "401 invalid_token (期限切れ)".into(),
        Ok(c) if !c["scope"].as_str().unwrap_or("").split(' ').any(|s| s == "api.read") => "403 insufficient_scope".into(),
        Ok(c) => format!("200 OK (sub={})", c["sub"].as_str().unwrap()),
    }
}

fn main() {
    let mut op = Op::new();
    let jwks = op.jwks();
    let now = 1_800_000_000;

    println!("[1] 正常なログインと API 呼び出し");
    let (mut rp, q) = Rp::login(&op.rng);
    let loc = op.authorize(&q, now).unwrap();
    let code = rp.parse_callback(&loc).unwrap();
    let form = HashMap::from([("grant_type", "authorization_code"), ("code", code.as_str()),
        ("redirect_uri", REDIRECT_URI), ("code_verifier", rp.verifier.as_str())]);
    let res = op.token(CLIENT_ID, CLIENT_SECRET, &form, now).unwrap();
    println!("  ID トークンの検証: {:?}", rp.validate_id_token(&res, &jwks, now));
    let at = res["access_token"].as_str().unwrap();
    println!("  API: {}", resource_server(&format!("Bearer {at}"), &jwks, now + 10));

    println!("[2] 同じ認可コードの再利用と、同じ認可レスポンスの再送");
    println!("  OP: {:?}", op.token(CLIENT_ID, CLIENT_SECRET, &form, now + 5).map(|_| "発行"));
    println!("  RP: {:?}", rp.parse_callback(&loc));

    println!("[3] 横取りしたコードを推測した code_verifier で交換する");
    let (mut rp3, q3) = Rp::login(&op.rng);
    let code3 = rp3.parse_callback(&op.authorize(&q3, now).unwrap()).unwrap();
    let stolen = HashMap::from([("grant_type", "authorization_code"), ("code", code3.as_str()),
        ("redirect_uri", REDIRECT_URI), ("code_verifier", "guess")]);
    println!("  {:?}", op.token(CLIENT_ID, CLIENT_SECRET, &stolen, now).map(|_| "発行"));

    println!("[4] 攻撃者が自分の認可レスポンスを被害者の RP に送る");
    let (_attacker, qa) = Rp::login(&op.rng);
    let (mut victim, _) = Rp::login(&op.rng);
    println!("  {:?}", victim.parse_callback(&op.authorize(&qa, now).unwrap()));

    println!("[5] 登録されていない redirect_uri");
    let mut q5 = q.clone();
    q5.insert("redirect_uri".into(), "https://attacker.example/cb".into());
    println!("  {:?}", op.authorize(&q5, now));

    println!("[6] ID トークンをリソースサーバに送る");
    println!("  {}", resource_server(&format!("Bearer {}", res["id_token"].as_str().unwrap()), &jwks, now));

    println!("[7] アクセストークンのペイロードを書き換える");
    let parts: Vec<&str> = at.split('.').collect();
    let forged = B64.encode(String::from_utf8(B64.decode(parts[1]).unwrap()).unwrap().replace("user-0001", "user-0002"));
    println!("  {}", resource_server(&format!("Bearer {}.{forged}.{}", parts[0], parts[2]), &jwks, now));

    println!("[8] 有効期限の後に API を呼ぶ");
    println!("  {}", resource_server(&format!("Bearer {at}"), &jwks, now + 301));
}

鍵とトークンは実行ごとに生成し直しますが、表示される判定は毎回同じです。

[1]は正常な流れです。RP は認可レスポンスの state と iss を照合し、 コードを交換して ID トークンを検証し、sub が user-0001 であることを確かめています。 リソースサーバは同じ JWKS でアクセストークンを検証し、200 を返しています。

[2]から[8]は、攻撃を最初に拒否した検査の結果です。

番号 攻撃 最初に拒否した検査 説明した章
2 認可コードの再利用と認可レスポンスの再送 OP のコードの1回限りの取り出しと、RP の state の1回限りの取り出し 11章、12章
3 横取りしたコードの交換 PKCE 9章
4 攻撃者の認可レスポンスの送信(ログイン CSRF) state の照合 9章
5 攻撃者の URI へのリダイレクト redirect_uri の完全一致 22章
6 ID トークンを API に送る typ の検査 14章
7 トークンの改ざん 署名の検証 7章
8 期限切れのトークンの使用 exp の検査 8章

表の検査を1つ取り除いても、攻撃が成功するとは限りません。多くの攻撃は、 複数の検査で重ねて防がれているためです。[3]は、コードを横取りした攻撃者が クライアントシークレットも得ている場合を想定しています。この攻撃は PKCE だけが防いでおり、 PKCE の検査を取り除くと攻撃者はトークンを得ます。一方、[4]で state の検査を外した場合は、 被害者の RP がコードを交換すると PKCE の検査で失敗します(nonce の検査でも失敗します)。 攻撃者のコードは攻撃者の code_verifier に結びついているためです。[6]は typ の検査を外しても、 ID トークンの aud がリソースサーバの識別子ではないため、aud の検査で拒否されます。 コードを編集して検査を1つずつ外し、どの攻撃がどの検査でまだ拒否されるかを確かめてください。 1つの攻撃を複数の検査で防いでいる構成では、1つの実装の誤りがそのまま被害には結びつきません。 検査を省くかどうかは、その検査だけが防いでいる攻撃があるかどうかで判断します。

この簡易実装には、実際のシステムに必要なものが欠けています。鍵の保管とローテーション (26章)、ログインと同意の画面(12章)、 セッション Cookie とセッション ID の再発行(11章)、 リフレッシュトークン(15章)、ログアウト (21章)です。これらを含む実装は、examples/ の mini-op・rp・resource-server を参照してください。

本書の知識の一覧

最後に、本書の主要な概念を、それを定める仕様と、説明した章に対応づけて一覧にします。 仕様の要約はRFC・仕様の索引に、用語の定義は用語集にあります。

実装

Discovery・JWKS
10章

RP
11章

OP
12章

OpenID Connect

IDトークン
6章

JOSE
7章

検証
8章

state・nonce・PKCE
9章

OAuth 2.0

ロールと委譲
2章

認可コードフロー
3章

トークン
4章

前提

HTTP・Cookie
1章

暗号
5章

OAuthの深層
13〜17章

OIDCの深層
18〜21章

セキュリティ
22〜25章

運用と周辺
26〜28章

この図は本書の構成をそのまま示しています。基礎編の12章は左から順に前提となる関係にあり、 応用編の各 Part は基礎編の全体を前提にしています。

概念 何を解決するか 仕様 章
Basic 認証・セッション Cookie ステートレスな HTTP での認証の引き継ぎ RFC 7617、RFC 6265 1章
委譲と4つのロール パスワードを渡さずに限られた権限を渡す RFC 6749 1節 2章
認可コードフロー トークンをブラウザに出さずにクライアントへ渡す RFC 6749 4.1節 3章
Bearer トークンとスコープ 権限の範囲を表し、API に提示する RFC 6749 5節、RFC 6750 4章
ハッシュ・HMAC・署名 改ざんの検出と発行者の確認 RFC 7518 5章
ID トークン 認証の結果を、宛先と発行者を明示して RP に伝える OIDC Core 2節 6章
JWS・JWT・JWK 署名付きのクレームと公開鍵の表現 RFC 7515、7517、7519 7章
ID トークンの検証 他人宛て・偽造・期限切れの ID トークンの拒否 OIDC Core 3.1.3.7節 8章
state・nonce・PKCE フローの各段を同じブラウザ・同じリクエストに結びつける RFC 6749 10.12節、RFC 7636 9章
Discovery・JWKS OP の設定値と公開鍵の取得 OIDC Discovery、RFC 8414 10章
クライアント認証 トークンエンドポイントでのクライアントの確認 RFC 6749 2.3節、RFC 7523、RFC 8705 13章
JWT アクセストークン・イントロスペクション リソースサーバでのトークンの検証 RFC 9068、RFC 7662 14章
リフレッシュトークンのローテーション 長期の権限の更新と漏洩の検出 RFC 6749 6節、RFC 9700 15章
DPoP・mTLS 漏洩したトークンの使用の防止 RFC 9449、RFC 8705 16章
PAR・JAR・iss 認可リクエストとレスポンスの保護 RFC 9126、9101、9207 17章
iss+sub による識別 アカウントの乗っ取りの防止 OIDC Core 5.7節、8節 18章
response_type と response_mode フローの種類と応答の返し方の選択 Multiple Response Types 19章
デバイス・クライアントクレデンシャル・トークン交換 認可コードフローを使えない場面 RFC 8628、8693 20章
ログアウト 複数のセッションの終了 RP-Initiated / Front / Back-Channel Logout 21章
脅威モデル 攻撃者の能力の整理と対策の対応づけ RFC 6819、RFC 9700 22章
JWT への攻撃 alg の混同、型の取り違え RFC 8725 23章
BFF・ネイティブアプリ クライアントの実行環境ごとの安全な構成 RFC 10017、RFC 8252 24章
OAuth 2.1・FAPI 2.0 選択肢を減らした安全な既定値 draft-ietf-oauth-v2-1、FAPI 2.0 25章
鍵のローテーション・マルチテナント 運用中に変わる検証の前提への対応 OIDC Core 10.1.1節 26章
SD-JWT・Federation・MCP OIDC の周辺にある標準 RFC 9901、OpenID Federation 27章

まとめ

  • 設計は「目的 → クライアントの実行場所 → フロー → クライアント認証 → トークンの形式 → 追加の対策 → 検査の実装」の順に決めます
  • ログインには ID トークンを使い、アクセストークンでログインを実装してはなりません
  • フローは原則として認可コードフロー + PKCE です。SPA では BFF を置き、ネイティブアプリでは外部ユーザーエージェントを使います
  • クライアント認証は、認可サーバの保存値でなりすましができない private_key_jwt や mTLS が、共有シークレットより強い方式です
  • JWT アクセストークンは寿命を短くし、typ で ID トークンと区別します。失効の即時性が必要なら不透明トークンとイントロスペクションを使います
  • RP・OP・リソースサーバの検査は、それぞれ特定の攻撃に対応しています。1つの攻撃が複数の検査で重ねて防がれていることも多くあります。検査を省くときは、その検査だけが防いでいる攻撃がないことを説明できなければなりません

OAuth と OpenID Connect の仕様は、現在も更新が続いています。本書で扱った OAuth 2.1 や JWT BCP の改訂版のように、策定中の文書が RFC になると、推奨事項が変わることがあります。 新しい文書を読むときは、RFCの読み方の手順に従い、 その文書がどの攻撃に対処し、本書のどの検査を更新するものかを確かめてください。