コンテンツにスキップ

OpenID Connect

OpenID Connect — OIDC Core 1.0

  • OpenID Connect は OAuth 2.0 の上に載る認証レイヤ。OIDC Core 1.0 §1 が自身を “a simple identity layer on top of the OAuth 2.0 protocol” と説明する。 OAuth 2.0 が「何をしてよいか」を扱うのに対し、OIDC は**「誰がいつ認証されたか」**を扱う。
  • 追加されるものは実質 3 つ。scopeopenid を入れるid_token(署名付き JWT)が返るUserInfo エンドポイントが使える。フローそのものは認可コードフローのまま。
  • 難所は id_token の検証。 OIDC Core §3.1.3.7 が 13 ステップを定めており、 署名検証はそのうちの 1 つでしかない。aud / nonce / expクライアント自身が MUST で検証する

OIDC は RFC ではない。 OpenID Foundation が発行する仕様で、番号ではなく errata set の版で管理されている。

文書 版・日付 扱う範囲
OIDC Core 1.0 errata set 2 / 2023-12-15 ID トークン、フロー、クレーム、UserInfo
OIDC Discovery 1.0 errata set 2 / 2023-12-15 /.well-known/openid-configuration
RP-Initiated Logout 1.0 Final / 2022-09-12 end_session_endpoint
Front-Channel Logout 1.0 Final / 2022-09-12 iframe 経由のログアウト伝播
Back-Channel Logout 1.0 errata set 1 / 2023-12-15 サーバー間のログアウト通知
RFC 9700 BCP 240 / 2025 implicit の可否、nonce と PKCE の関係
RFC 9101 Standards Track 認可リクエストを JWT で送る(JAR)

Core 2.0 のドラフトは存在しない。 現行は errata set 2 が最新で、 Core 1.0 と書かれていても中身は 2023 年の版。

フローは変わらない。増えるのは値と検証手順だけ。

flowchart TB
    subgraph OAUTH["OAuth 2.0 (認可) — 変わらない部分"]
        A1["認可コードフロー + PKCE"]
        A2["access_token / refresh_token"]
        A3["scope による権限の絞り込み"]
    end

    subgraph OIDC["OpenID Connect (認証) — 足される部分"]
        B1["scope に openid を入れる"]
        B2["id_token が返る (署名付き JWT)"]
        B3["UserInfo エンドポイント"]
        B4["nonce / auth_time / acr"]
    end

    OAUTH --> OIDC
    OIDC --> USE["誰がログインしたかを<br/>クライアントが確認できる"]

    style OIDC fill:#4054b2,color:#fff
    style USE fill:#1f5c33,color:#fff
access token ID token
何を表すか このクライアントは何をしてよいか 誰がいつ認証されたか
宛先 リソースサーバー クライアント自身aud = client_id
形式 不透明でも JWT でもよい 必ず署名付き JWT
中身を読むのは リソースサーバー クライアント
API に送るか 送る 送らない
何度も使うか 有効期限まで使う ログイン時に 1 回検証して捨てる

ID トークンは自分が確認する身分証、access token は相手に渡す鍵という対比

id_token は身分証、access token は鍵。 身分証は自分が受け取って確認するもので、 どこかへ提出して回るものではない。鍵は相手に渡して使わせるもの。 id_token を API へ送るのは、身分証を鍵穴に差そうとしているのと同じ。

id_token は「使い続けるトークン」ではない。 受け取って検証して、 結果を自前のセッション(Cookie とセッション)に落としたら役目は終わる。

認可コードフローに openid scope と nonce を足しただけ。

sequenceDiagram
    autonumber
    participant UA as 利用者 (ブラウザ)
    participant RP as RP (クライアント)
    participant OP as OP (認可サーバー)

    UA->>RP: ログインを開始する
    RP->>RP: state / nonce / code_verifier<br/>を生成しセッションに保存
    RP-->>UA: 302 認可エンドポイントへ
    UA->>OP: GET /authorize<br/>scope=openid profile<br/>nonce / state / code_challenge
    OP-->>UA: ログイン画面と同意画面
    UA->>OP: 認証して scope に同意
    OP-->>UA: 302 redirect_uri へ<br/>code / state / iss
    UA->>RP: GET /callback
    RP->>OP: POST /token<br/>code と code_verifier
    OP-->>RP: access_token / id_token<br/>refresh_token
    RP->>RP: id_token を 13 ステップで検証<br/>nonce を照合する
    RP->>OP: GET /userinfo<br/>Authorization: Bearer ...
    OP-->>RP: sub とクレーム
    RP->>RP: UserInfo の sub が<br/>id_token の sub と一致するか
    RP-->>UA: 自前のセッション Cookie を発行

11 番と 14 番がこのページの主題。 OAuth 2.0 のフローとの差はここにしかない。

利用者から見えるのは 5 番と 6 番だけで、実際にはこういう画面になる。

RP のログイン画面のソーシャルログインボタンと、OP 側のアカウント選択画面

左が RP、右が OP の画面。 左のコーラルで消してある入力欄がパスワード欄で、 RP 側にはパスワード欄が存在しないことを表している。認証は右の OP 側で完結する。

右は prompt=select_account を送ったときに出るアカウント選択で、 複数アカウントを持つ利用者にどれで入るかを選ばせる (後述)。

用語も OAuth と対応している。試験では OIDC の用語で問われる。

OIDC の用語 OAuth 2.0 の用語
RP(Relying Party) client
OP(OpenID Provider) authorization server
End-User resource owner
ID Token (対応なし。OIDC が追加した)

実値で追う。isshttps://as.example.comclient_ids6BhdRkqt3署名は HS256 にしてある(読者が手元で再現できるように)。 実際の OP は RS256 か ES256 を使う — 理由は後述

OIDC を有効にする唯一のスイッチ。Core §3.1.2.1 は “Must contain the openid scope value” と定める。

GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=mkHXw-BfKLY
&nonce=n-0S6_WzA2Mj
&code_challenge=sOiV9ZgIX37UdtxEUzYQz7zKdaDeBwRb8ilMwpFVg4c
&code_challenge_method=S256 HTTP/1.1
Host: as.example.com

openid を落とすと id_token が返らず、ただの OAuth になる。 profileemail が増えているのは、返してほしいクレームの指定(後述)。

2. nonce の要否はフローで変わる

Section titled “2. nonce の要否はフローで変わる”

ここが最も取り違えられる。 Core §3.1.2.1 の nonce の扱いは一様ではない。

フロー nonce の要否
認可コードフローresponse_type=code OPTIONAL
implicit フロー(response_type=id_token など) REQUIRED
hybrid フロー(code id_token / code id_token token REQUIRED

nonce は常に必須」は誤り。ただし認可コードフローでも入れる。 理由は 2 つ。

  1. nonce を送ったら、id_tokennonce クレームが必ず含まれ、値の照合が MUST になる (Core §3.1.3.7 ステップ 11)。送らなければこの検証が発生しない
  2. RFC 9700 §2.1.1 が、confidential な OIDC クライアントに限り PKCE の代わりに nonce を使ってよい(MAY)としている。裏を返せば nonce には固有の防御価値がある

3. トークンレスポンスに id_token が増える

Section titled “3. トークンレスポンスに id_token が増える”
{
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDkifQ.eyJpc3MiOiJodHRwczovL2FzLmV4YW1wbGUuY29tIiwic3ViIjoiMjQ4Mjg5NzYxMDAxIiwiYXVkIjoiczZCaGRSa3F0MyIsImV4cCI6MTc4OTAwMzYwMCwiaWF0IjoxNzg5MDAwMDAwLCJhdXRoX3RpbWUiOjE3ODg5OTk5NzAsIm5vbmNlIjoibi0wUzZfV3pBMk1qIiwiYXRfaGFzaCI6ImJKWVREeE1Lc05iUldEbC1KTks4d1EifQ.Wk7Wkvby3HWLX3QED44LAQWAWD4rYbTQXnxflt11QAY"
}

id_token は 353 文字。. で 3 つに割れるのが JWT (詳細は Bearer トークン)。

ヘッダ(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDkifQ、58 文字):

{ "alg": "HS256", "typ": "JWT", "kid": "2026-09" }

ペイロード(250 文字):

{
"iss": "https://as.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"exp": 1789003600,
"iat": 1789000000,
"auth_time": 1788999970,
"nonce": "n-0S6_WzA2Mj",
"at_hash": "bJYTDxMKsNbRWDl-JNK8wQ"
}

手元で確認できる(macOS の base64 は復号が -D):

ターミナルウィンドウ
ID_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDkifQ.eyJpc3MiOiJodHRwczovL2FzLmV4YW1wbGUuY29tIiwic3ViIjoiMjQ4Mjg5NzYxMDAxIiwiYXVkIjoiczZCaGRSa3F0MyIsImV4cCI6MTc4OTAwMzYwMCwiaWF0IjoxNzg5MDAwMDAwLCJhdXRoX3RpbWUiOjE3ODg5OTk5NzAsIm5vbmNlIjoibi0wUzZfV3pBMk1qIiwiYXRfaGFzaCI6ImJKWVREeE1Lc05iUldEbC1KTks4d1EifQ.Wk7Wkvby3HWLX3QED44LAQWAWD4rYbTQXnxflt11QAY'
b64url_decode() { local s="$1"; s="${s//-/+}"; s="${s//_//}"
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="${s}=" ; done
printf '%s' "$s" | base64 -D ; }
b64url_decode "$(echo "$ID_TOKEN" | cut -d. -f2)" | jq .

Core §2。REQUIRED は 5 つだけで、あとは条件付き。

クレーム 要否 意味と注意
iss REQUIRED 発行者。完全一致で照合する
sub REQUIRED 利用者の識別子。“MUST NOT exceed 255 ASCII characters”
aud REQUIRED “MUST contain the OAuth 2.0 client_id。配列になりうる
exp REQUIRED 有効期限。UTC の 1970 年からの秒数
iat REQUIRED 発行時刻。古すぎるトークンを弾くのに使う
auth_time OPTIONAL 利用者が実際に認証された時刻。 max_age を送ったら実質必須
nonce OPTIONAL 送ったなら必ず入り、照合が MUST になる
acr OPTIONAL 認証の強度(多要素かどうかなど)
amr OPTIONAL 認証手段の配列
azp OPTIONAL authorized party。あれば client_id と一致するか SHOULD
at_hash OPTIONAL access token のハッシュ。認可コードフローでのみ意味を持つ

6. at_hash はハッシュの左半分だけを使う

Section titled “6. at_hash はハッシュの左半分だけを使う”

この「左半分」が試験で問われる。 Core §3.1.3.8:

Hash the octets of the ASCII representation of the access_token … Take the left-most half of the hash and base64url-encode it.

ハッシュ関数は id_tokenalg に連動するHS256 / RS256 なら SHA-256、 HS512 なら SHA-512。

flowchart TB
    AT["access_token<br/>2YotnFZFEjr1zCsicMWpAA"]
    ALG["id_token の alg を見る<br/>HS256 / RS256 → SHA-256"]
    H["SHA-256 を計算<br/>32 バイト (256 bit)"]
    L["左 16 バイト (128 bit) だけ取る<br/>右半分は捨てる"]
    B["base64url にする<br/>bJYTDxMKsNbRWDl-JNK8wQ"]

    AT --> ALG --> H --> L --> B

    style L fill:#7a4a12,color:#fff
    style B fill:#4054b2,color:#fff

シェルで再現できる。head -c 16 が「左半分」そのもの:

ターミナルウィンドウ
b64url() { base64 | tr '+/' '-_' | tr -d '=' ; }
printf '%s' '2YotnFZFEjr1zCsicMWpAA' | openssl dgst -binary -sha256 | head -c 16 | b64url
# bJYTDxMKsNbRWDl-JNK8wQ ← id_token の at_hash と一致する

同じ計算を認可コードに対してやったものが c_hash(hybrid フローで使う):

ターミナルウィンドウ
printf '%s' 'SplxlOBeZQQYbYS6WxSbIA' | openssl dgst -binary -sha256 | head -c 16 | b64url
# o1uBp9eSe3DsmScN0jYriA
クレーム 対象 使う場面
at_hash access_token 認可コードフロー(id_token に含まれるとき)
c_hash 認可コード code hybrid フローでは REQUIRED

目的は「一緒に返ってきた値がすり替えられていないか」の確認。 フロントチャネルで id_token と access token が同時に返る hybrid フローでは、 この結び付けがないと別々に差し替えられる。

7. ID トークンの検証(13 ステップ)

Section titled “7. ID トークンの検証(13 ステップ)”

Core §3.1.3.7 が定める。署名検証はこのうちの 1 つでしかない。

# 内容 強度
1 暗号化されていれば復号する 条件付き
2 iss が OP の Issuer 識別子と完全一致するか MUST
3 aud に自分の client_id含まれる MUST
4 aud に自分以外が含まれるなら扱いを決めておく MUST
5 azp があれば client_id と一致するか SHOULD
6 トークンエンドポイントから直接受け取ったなら TLS の検証で代替してよい MAY
7 alg が登録した id_token_signed_response_alg(既定 RS256)と一致するか SHOULD
8 MAC 系(HS256 など)なら client_secret の UTF-8 バイト列を鍵にする
9 現在時刻が exp より前であるか MUST
10 iat が古すぎないか(許容範囲は実装が決める) MAY
11 nonce を送ったなら、nonce クレームがあり値が一致するか MUST
12 acr を要求したなら妥当な値か SHOULD
13 max_age を送ったなら auth_time を見て再認証が必要か判断する SHOULD
flowchart TB
    IN["id_token を受け取る"]
    S["署名を検証する<br/>jwks_uri の鍵 / kid で選ぶ"]
    C2{"iss が完全一致するか"}
    C3{"aud に自分の client_id が<br/>含まれるか"}
    C9{"現在時刻 < exp か"}
    C11{"送った nonce と<br/>nonce クレームが一致するか"}
    OK["ログイン成立<br/>自前のセッションを発行"]
    NG["拒否する<br/>ログインさせない"]

    IN --> S
    S -->|"失敗"| NG
    S -->|"成功"| C2
    C2 -->|"いいえ"| NG
    C2 -->|"はい"| C3
    C3 -->|"いいえ"| NG
    C3 -->|"はい"| C9
    C9 -->|"いいえ"| NG
    C9 -->|"はい"| C11
    C11 -->|"いいえ"| NG
    C11 -->|"はい"| OK

    style OK fill:#1f5c33,color:#fff
    style NG fill:#7a2222,color:#fff

Core §5.3。access token を Bearer として提示して呼ぶ。

ターミナルウィンドウ
curl -s https://as.example.com/userinfo \
-H 'Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA' | jq .
{
"sub": "248289761001",
"name": "山田 太郎",
"preferred_username": "taro",
"email": "taro@example.com",
"email_verified": true
}

返ってきた subid_tokensub と一致することを確認しなければならない。 仕様の文言(Basic Client Implementer’s Guide §2.3.2、Core §5.3.2 と同内容):

Due to the possibility of token substitution attacks, the UserInfo Response is not guaranteed to be about the End-User identified by the sub … The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token

理由は access token が Bearer トークンだから。 別人の access token を握らされたら、UserInfo はその別人の情報を返す。 id_tokensub と突き合わせて初めて「同じ人の話」だと確定する。

9. UserInfo は必須の経路ではない

Section titled “9. UserInfo は必須の経路ではない”

id_token に直接クレームを入れられる。 UserInfo を呼ばないと利用者情報が取れない、 というのは誤り。

取り方 いつ選ぶか
id_token のクレームを読む 1 往復で済む。 変わらない属性(subemail)向け
UserInfo を呼ぶ 属性が多い・変わりうる。id_token を小さく保ちたい

OP によって「id_token にどこまで入れるか」の既定が違う。 両方に対応するコードを書くのが安全で、id_token に無ければ UserInfo を引く。

3 つとも「返ってきた応答を信じてよいか」に関わるが、防ぐものが違う。 OAuth 2.0 のページstate / PKCE / iss を分けたが、 OIDC ではここに nonce が加わる。

flowchart TB
    Q["コールバックで受けた値を<br/>信じてよいか"]
    S["state<br/>セッションに保存した値"]
    P["PKCE<br/>code_verifier"]
    N["nonce<br/>id_token のクレーム"]

    Q --> S
    Q --> P
    Q --> N

    S --> SA["自分が始めたフローか<br/>(CSRF)"]
    P --> PA["この code を交換しに来たのは<br/>認可要求を出した本人か"]
    N --> NA["この id_token は<br/>今回のログインのものか<br/>(リプレイ / トークン注入)"]

    style S fill:#4054b2,color:#fff
    style P fill:#4054b2,color:#fff
    style N fill:#1f5c33,color:#fff
仕組み 生成する側 経路 照合する場所
state RP 認可リクエスト → 認可レスポンス RP のセッションと突き合わせる
code_verifier RP フロントチャネルに出ない OP がハッシュを突き合わせる
nonce RP 認可リクエスト → id_token のクレーム RP のセッションと突き合わせる

「PKCE があれば nonce は不要」は条件付きで誤り

Section titled “「PKCE があれば nonce は不要」は条件付きで誤り”

RFC 9700 §2.1.1 の文言は限定的:

With additional precautions … confidential OpenID Connect clients MAY use the nonce parameter and the respective Claim in the ID Token instead.

読み方は「PKCE が第一選択、confidential な OIDC クライアントに限り nonce で代替可」。 逆向きに「nonce があるから PKCE 不要」でも、 「PKCE があるから nonce 不要」でもない。防ぐ対象が違う。

PKCE が防ぐもの nonce が防ぐもの
対象 認可コードの傍受・注入 id_token のリプレイ・注入
検証する側 OP(トークンエンドポイント) RP(id_token を読むとき)
public クライアントで免除されるか されない(MUST) されない

実務の結論: 両方入れる。 認可コードフローでも nonce を送る。 コストは乱数 1 個とセッションの 1 フィールドしかない。

sub だけをユーザーの主キーにしてはいけない。 Core §2 の定義:

Subject Identifier. A locally unique and never reassigned identifier within the Issuer

within the Issuer一意なのは発行者の中だけ。「Google でログイン」と 「GitHub でログイン」の両方に対応すると、sub が衝突しうる。

flowchart TB
    K["利用者を一意に決める<br/>主キーは何か"]
    BAD["sub だけを使う"]
    GOOD["iss + sub の組を使う"]

    K --> BAD
    K --> GOOD

    BAD --> B1["別の OP の利用者と<br/>衝突しうる"]
    GOOD --> G1["OP をまたいでも衝突しない"]
    GOOD --> G2["同じ人が別の OP で入ったら<br/>別アカウントとして扱われる"]

    style BAD fill:#7a2222,color:#fff
    style GOOD fill:#1f5c33,color:#fff

email を主キーにするのはもっと悪い。 変わるし、email_verifiedfalse のこともあり、OP によっては使い回される。

Core §8。同じ利用者に対して、sub を全 RP で同じにするか RP ごとに変えるか。

種別 挙動(§8.1) 使う場面
public “The same sub value is returned to all clients” 社内 SSO。RP 間で利用者を突き合わせたい
pairwise “A different sub value is returned to each client” 一般公開の IdP。 RP 間の名寄せを防ぐ

pairwise が保証するのは “the End-User’s PPID at another Relying Party cannot be correlated with the End-User’s PPID at this Relying Party” — つまり複数の RP が結託しても同一人物だと分からない

Discovery — well-known のパスが RFC 8414 と違う

Section titled “Discovery — well-known のパスが RFC 8414 と違う”

ここは実際に踏む。 OIDC Discovery §4 と RFC 8414 §3.1 で /.well-known/ を挿入する位置が違う

Issuer が https://as.example.com/tenant1 のとき:

仕様 取得先 URL
OIDC Discovery https://as.example.com/tenant1/.well-known/openid-configuration
RFC 8414 https://as.example.com/.well-known/oauth-authorization-server/tenant1

OIDC は Issuer の後ろに連結する (“the path formed by concatenating the string /.well-known/openid-configuration to the Issuer”)。 RFC 8414 は ホストとパスの間に挿入する (“insert … between the host component and the path component”)。

Issuer にパスが付かない(https://as.example.com)なら結果は同じなので、 マルチテナントの OP を扱うまで気づかない。

ターミナルウィンドウ
curl -s https://as.example.com/.well-known/openid-configuration | jq .
{
"issuer": "https://as.example.com",
"authorization_endpoint": "https://as.example.com/authorize",
"token_endpoint": "https://as.example.com/token",
"userinfo_endpoint": "https://as.example.com/userinfo",
"jwks_uri": "https://as.example.com/jwks.json",
"end_session_endpoint": "https://as.example.com/logout",
"response_types_supported": ["code", "code id_token"],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"],
"code_challenge_methods_supported": ["S256"]
}

REQUIRED なのは issuer / authorization_endpoint / response_types_supported / subject_types_supported / id_token_signing_alg_values_supported / jwks_uriuserinfo_endpoint は RECOMMENDED で、REQUIRED ではない。

検証ステップ 7 は「alg登録した id_token_signed_response_alg(既定 RS256)と 一致するか」を SHOULD で求めている。トークンの中の alg を読んで、それに従って 検証してはいけない。

やること やってはいけないこと
期待する alg自分のコードに書く トークンの alg を読んで分岐する
jwks_uri から公開鍵を取り、kid で選ぶ トークンの jku / x5u を取得する
alg: none無条件に拒否する ライブラリの既定に任せる

これは Bearer トークンのページで扱った RFC 8725 の アルゴリズム混同と同じ話。RS256 を期待している検証器に HS256 の トークンを投げると、公開鍵が HMAC の共有鍵として使われてしまう (公開鍵は誰でも入手できるので、攻撃者が任意のトークンを署名できる)。

このページの例で HS256 を使ったのは読者が手元で再現できるようにするためで、 実運用では RS256ES256 を使う。HS256client_secret を鍵に使う (ステップ 8)ので、public クライアントでは成立しない。

Core §5.4。scope を足すと返るクレームが増える。

scope 返るクレーム
openid (必須。id_token が返るようになる)
profile name family_name given_name middle_name nickname preferred_username profile picture website gender birthdate zoneinfo locale updated_at
email email email_verified
address address
phone phone_number phone_number_verified

profile は 14 個まとめて要求する粗い scope。 名前だけ欲しいときも 生年月日や性別まで要求することになるので、同意画面が重くなる。 細かく切りたいなら claims パラメータで個別に要求する。

Core は 3 つのフローを定めるが、新規に選ぶのは 1 つだけ

フロー response_type トークンの返り方 現在の評価
認可コード code すべてトークンエンドポイント経由 これを使う
implicit id_token / id_token token すべてフラグメントで返る RFC 9700 §2.1.2 が SHOULD NOT
hybrid code id_token など code + 一部がフラグメント 条件付きで許容

RFC 9700 §2.1.2 が SHOULD NOT にしているのは “the implicit grant (response type token) or other response types issuing access tokens in the authorization response” — つまり access token をフロントチャネルに載せる方式

同じ節が代替として “such as the code id_token response type” を挙げている。 code id_tokenid_token はフラグメントで返るが access token は トークンエンドポイント経由なので、この禁止に当たらない。

flowchart TB
    Q1{"新規に作るか"}
    Q2{"認可レスポンスで<br/>access_token を<br/>受け取るか"}
    Q3{"id_token を先に<br/>受け取る必要があるか"}

    CODE["認可コードフロー<br/>response_type=code<br/>+ PKCE + nonce"]
    HYB["hybrid<br/>code id_token<br/>c_hash の検証が必要"]
    IMP["implicit は使わない<br/>RFC 9700 SHOULD NOT"]

    Q1 -->|"はい"| CODE
    Q1 -->|"既存の移行"| Q2
    Q2 -->|"はい"| IMP
    Q2 -->|"いいえ"| Q3
    Q3 -->|"はい"| HYB
    Q3 -->|"いいえ"| CODE

    style CODE fill:#1f5c33,color:#fff
    style HYB fill:#7a4a12,color:#fff
    style IMP fill:#7a2222,color:#fff

hybrid を選ぶなら c_hash の検証が増える。 認可コードフローで済むなら そちらのほうが実装が単純で、検証項目も少ない。

OIDC のログアウトは 3 つの独立した仕様に分かれている。 混同しやすい。

flowchart TB
    L["ログアウトさせたい"]
    RPI["RP-Initiated Logout<br/>RP が OP へリダイレクト"]
    FC["Front-Channel Logout<br/>OP が iframe を並べる"]
    BC["Back-Channel Logout<br/>OP が RP へ直接 POST"]

    L --> RPI
    L --> FC
    L --> BC

    RPI --> R1["利用者を OP から<br/>ログアウトさせる"]
    FC --> F1["他の RP にも伝える<br/>ブラウザ経由"]
    BC --> B1["他の RP にも伝える<br/>サーバー間"]

    F1 --> F2["3rd party Cookie が<br/>ブロックされると失敗する"]

    style BC fill:#1f5c33,color:#fff
    style F2 fill:#7a2222,color:#fff

RP が利用者を end_session_endpoint へリダイレクトする。

GET /logout?id_token_hint=eyJhbGciOi...
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fbye
&state=mkHXw-BfKLY HTTP/1.1
Host: as.example.com
パラメータ 要否
id_token_hint RECOMMENDED(誰をログアウトさせるかを OP に伝える)
logout_hint OPTIONAL
client_id OPTIONAL
post_logout_redirect_uri OPTIONAL(事前登録が必要)
state OPTIONAL

end_session_endpointメタデータでは REQUIRED(仕様に対応する OP なら必ず公開する)。

「他の RP にもログアウトを伝える」ための仕組みで、伝え方が違う。

Front-Channel Back-Channel
経路 OP が各 RP の URI を iframe で並べたページを返す OP が RP へ直接 POST
RP 側の登録 frontchannel_logout_uri backchannel_logout_uri
送るもの クエリの iss / sid(任意) logout_token(JWT)
ブラウザ依存 する しない
確実性 低い。3rd party Cookie がブロックされると RP のセッション Cookie に届かない 高い
制約 なし RP のエンドポイントが外部から到達可能である必要がある

Front-Channel の限界は仕様自身が明記している。 サードパーティ Cookie の ブロックが標準になった今、iframe から RP のセッション Cookie を消せる保証はない。 Cookie とセッションSameSite の話と同じ流れ。

Back-Channel の logout_token は普通の id_token ではない。

  • events クレームの値は http://schemas.openid.net/event/backchannel-logout
  • “MUST contain either a sub or a sid Claim, and MAY contain both”
  • nonce クレームは禁止id_token と取り違えて使われるのを防ぐため)

認可リクエストを JWT で送る(JAR)

Section titled “認可リクエストを JWT で送る(JAR)”

RFC 9101クエリパラメータは改ざんできる (“the communication through the user agents is not integrity protected”)。 これを JWT に載せて署名する。

パラメータ 渡し方
request 認可リクエストのパラメータ一式を JWS で署名した JWT として値渡し
request_uri その JWT を OP が取りに来る HTTPS URI(512 ASCII 文字以内)

金融系(FAPI)で必須になる。 通常の Web アプリでは state / nonce / PKCE で足りるが、「リクエスト自体の完全性」が要件になる場面で使う。

使ってよいケースと使ってはいけないケース

Section titled “使ってよいケースと使ってはいけないケース”

「OIDC は安全か」ではなく「認証を外部に委ねてよいか」で判断する。

  • 利用者の認証を外部の OP に委ねてよい(自前でパスワードを持たない選択ができる)
  • OP が Discovery(/.well-known/openid-configuration)を公開している
  • iss + sub を主キーにしたアカウント設計になっている
  • id_token13 ステップで検証する実装(またはライブラリ)がある
  • jwks_uri から鍵を取得し、kid で選び、ローテーションに追従できる
  • 認証後は自前のセッションに落とす(id_token を使い回さない)
  • subject_typespublic / pairwise)を最初に決めている

ひとつでも欠けたら止まる。 特に 3 つ目と 6 つ目が欠けたまま作ると、 後から直すのにデータ移行が必要になる。

OpenID Connect が成立する 6 つの条件を 1 枚にまとめた復習カード

試験前に見返すならこの 1 枚。 コーラルで示した iss + sub の複合キーは、 後から直すのに全ユーザーのデータ移行が必要になる唯一の項目。

flowchart TB
    Q1{"必要なのは<br/>認証か認可か"}
    Q2{"認証を外部の OP に<br/>委ねてよいか"}
    Q3{"OP は Discovery と<br/>jwks_uri を公開しているか"}

    OK["OIDC + 認可コードフロー<br/>+ PKCE + nonce"]
    NG1["OAuth 2.0 だけで足りる<br/>id_token は要らない"]
    NG2["自前のログインを作る<br/>Cookie セッション"]
    NG3["OP を選び直す<br/>手組みしない"]

    Q1 -->|"認可だけ"| NG1
    Q1 -->|"認証"| Q2
    Q2 -->|"いいえ"| NG2
    Q2 -->|"はい"| Q3
    Q3 -->|"いいえ"| NG3
    Q3 -->|"はい"| OK

    style OK fill:#1f5c33,color:#fff
    style NG1 fill:#7a2222,color:#fff
    style NG2 fill:#7a2222,color:#fff
    style NG3 fill:#7a2222,color:#fff
ケース 成立する理由 一緒にやること
「Google でログイン」「GitHub でログイン」 パスワードを自前で持たなくて済む iss + sub を主キーに。email は表示用
社内 SSO(複数の業務アプリ) 認証を 1 か所に集約でき、退職時の失効も 1 か所 subject_typespublic。Back-Channel Logout
B2B SaaS のテナントごとの IdP 接続 テナントが自社の IdP を使える Discovery を動的に取得。Issuer ごとにアカウントを分ける
モバイルアプリのログイン 外部ブラウザに認証を委ね、アプリはパスワードを見ない PKCE 必須。RFC 8252 に従い WebView を使わない
多要素認証の要求 acr_values / max_age で強度と鮮度を要求できる acrauth_time を実際に検証する
ケース できないこと
id_token を API のアクセス制御に使う aud がクライアント宛なので、リソースサーバー向けの認可判断には使えない。access token を使う
sub 単体を主キーにする OP をまたぐと衝突する。iss + sub にする
email を主キーにする 変わる。email_verified: false がありうる
id_token をセッション代わりに使い回す exp が数分。毎リクエストで検証するのは無駄で、失効もできない
implicit フローを新規採用する RFC 9700 §2.1.2 が SHOULD NOT
aud を検証しない 同じ OP の別クライアント向けトークンで通ってしまう
alg をトークンから読んで分岐する アルゴリズム混同攻撃を通してしまう
OP を自前でフルスクラッチする Core だけで膨大。Keycloak などを使う
種別
OP(SaaS) Google、Microsoft Entra ID、Auth0、Okta、Amazon Cognito
OP(セルフホスト) Keycloak、Ory Hydra、Authentik、Zitadel
RP 側ライブラリ openid-client(Node.js)、authlib(Python)、Spring Security OAuth2 Client
検証ツール OpenID Foundation の Certification
ターミナルウィンドウ
ISS=https://as.example.com
curl -s "$ISS/.well-known/openid-configuration" > oidc.json
# issuer が取得に使った URL と一致することを確認する(Discovery §4.3)
jq -r --arg iss "$ISS" 'if .issuer == $iss then "issuer OK" else "ISSUER MISMATCH" end' oidc.json
jq -r '.jwks_uri' oidc.json | xargs curl -s | jq '.keys[] | {kid, kty, alg, use}'

Node.js で ID トークンを検証する

Section titled “Node.js で ID トークンを検証する”

jose を使う。ライブラリに任せる部分と自分で書く部分を分ける。

import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://as.example.com';
const CLIENT_ID = 's6BhdRkqt3';
// jwks_uri は Discovery から取る。鍵はライブラリがキャッシュとローテーションを扱う
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/jwks.json`));
export async function verifyIdToken(idToken, { nonce, maxAge }) {
// ステップ 2 / 3 / 7 / 9 はここで検証される
const { payload } = await jwtVerify(idToken, jwks, {
issuer: ISSUER, // iss の完全一致 (MUST)
audience: CLIENT_ID, // aud に client_id を含むか (MUST)
algorithms: ['RS256'], // 期待する alg を固定する。トークンから読まない
clockTolerance: 5, // 秒。時計のずれの許容
});
// ステップ 11: nonce はライブラリが見ないので自分で照合する (MUST)
if (nonce && payload.nonce !== nonce) {
throw new Error('nonce mismatch');
}
// ステップ 5: azp があれば client_id と一致するか (SHOULD)
if (payload.azp && payload.azp !== CLIENT_ID) {
throw new Error('azp mismatch');
}
// ステップ 13: max_age を送ったなら auth_time の鮮度を見る (SHOULD)
if (maxAge != null) {
if (typeof payload.auth_time !== 'number') throw new Error('auth_time missing');
if (Date.now() / 1000 - payload.auth_time > maxAge) throw new Error('re-authentication required');
}
return payload;
}

nonce を検証するライブラリは少ない。 josejwtVerify では見ない。 「ライブラリを通したから大丈夫」で最も落ちやすいのがここ。

import crypto from 'node:crypto';
// alg が *256 なら SHA-256、*384 なら SHA-384、*512 なら SHA-512
function expectedHash(value, alg) {
const sha = `sha${alg.slice(-3)}`; // RS256 -> sha256
const digest = crypto.createHash(sha).update(value, 'ascii').digest();
return digest.subarray(0, digest.length / 2).toString('base64url'); // 左半分だけ
}
export function verifyAtHash(payload, accessToken, alg) {
if (!payload.at_hash) return; // OPTIONAL なので無ければ飛ばす
if (payload.at_hash !== expectedHash(accessToken, alg)) {
throw new Error('at_hash mismatch');
}
}

検証後は自前のセッションに落とす

Section titled “検証後は自前のセッションに落とす”
app.get('/callback', async (req, res) => {
// state / PKCE の検証は OAuth 2.0 のページと同じ
const tokens = await exchangeCode(req.query.code, req.session.codeVerifier);
const claims = await verifyIdToken(tokens.id_token, { nonce: req.session.nonce });
verifyAtHash(claims, tokens.access_token, 'RS256');
// 使い終わった一時値は捨てる
delete req.session.nonce;
delete req.session.codeVerifier;
delete req.session.state;
// iss + sub の複合キーでユーザーを引く。sub 単体では引かない
const user = await findOrCreateUser({ iss: claims.iss, sub: claims.sub });
// ここから先は自前のセッション。id_token は保存しない
req.session.userId = user.id;
res.redirect('/');
});

id_token はここで捨てる。 保存するのは RP-Initiated Logout の id_token_hint に使う場合だけで、その場合も 「ログアウトのため」と用途を限定する。

鍵のローテーションに追従する

Section titled “鍵のローテーションに追従する”

jwks_uri の鍵は入れ替わる。kid でヘッダの鍵を選び、見つからなければ JWKS を再取得するのが正しい挙動。

やること 理由
jwks_uri の応答をキャッシュする 毎回取ると OP に負荷がかかる
kid が未知なら再取得を 1 回だけ試す ローテーション直後に対応できる
再取得にレート制限をかける 未知の kid を大量に投げられると DoS になる
jku / x5u見ない トークンが鍵の取得先を指定できてしまう

createRemoteJWKSet はこの挙動を実装している。自前で書くなら 4 つ全部要る。

exp / iat / auth_time はすべて秒単位の絶対時刻。 サーバーの時計がずれていると正しいトークンを弾く。

  • NTP を入れる
  • clockTolerance数秒にする。分単位にすると exp の意味が薄れる
  • iat が未来のトークンは拒否する
扱い
id_token 残さない。 個人情報(emailname)が平文で入っている
access_token / refresh_token 残さない
nonce / state 残ってよい
sub 仮名だが個人の識別子。 扱いは利用者 ID と同じにする

id_token は Base64 で読めるだけなので「暗号化されている」ように見える。 Basic 認証のページの「Base64 は暗号化ではない」がそのまま効く。

promptmax_age で認証をやり直させる

Section titled “prompt と max_age で認証をやり直させる”
パラメータ 効果
prompt=none 画面を出さない。 すでにログイン済みかを確認するだけ(失敗なら login_required
prompt=login 強制的に再認証させる
prompt=consent 同意画面を再表示する
prompt=select_account アカウント選択画面を出す
max_age=300 5 分以内に認証していなければ再認証させる。 auth_time で確認する

prompt=noneサイレント更新に使う。iframe で叩いてセッションの生存を確認するが、 サードパーティ Cookie のブロックで動かなくなったので、 今は refresh token のローテーションで代替するのが実務。

  • OIDC は RFC ではない。OpenID Foundation の仕様で、現行は Core 1.0 errata set 2
  • scopeopenid が無ければ id_token は返らない
  • nonce認可コードフローでは OPTIONAL、implicit / hybrid では REQUIRED
  • それでも認可コードフローで nonce を送る。送って初めて照合が MUST になる
  • 「PKCE があれば nonce 不要」は誤り。代替が許されるのは confidential クライアントのみ
  • id_tokenaudclient_id。API へ送るトークンではない
  • aud の検証は署名検証とは別の MUST。ライブラリに audience を渡さないと見ない
  • nonce の照合はライブラリがやらないことが多い。自分で書く
  • alg登録した値と一致するかを見る。トークンの alg に従って分岐しない
  • at_hash / c_hashハッシュの左半分だけを base64url にしたもの
  • subIssuer 内でのみ一意。主キーは iss + sub
  • sub255 ASCII 文字以内
  • email を主キーにしない。email_verified を見ずに紐付けない
  • expトークンの寿命auth_time利用者が認証した時刻。別物
  • Discovery のパスは Issuer の後ろに連結。RFC 8414 はホストとパスの間に挿入
  • 取得した設定の issuer が取得 URL と一致するか を検証する
  • userinfo_endpoint はメタデータで RECOMMENDED(REQUIRED ではない)
  • UserInfo の subid_tokensub一致するか検証する(MUST)
  • Front-Channel Logout はサードパーティ Cookie のブロックで失敗する。新規なら Back-Channel
  • logout_tokensubsid のどちらかを必ず持つnonce は禁止
  • 検証が済んだら自前のセッションに落とすid_token を使い回さない
OpenID Connect と OAuth 2.0 の関係を 1 行で。

OIDC は OAuth 2.0 の上に載る認証レイヤ。 Core §1 が “a simple identity layer on top of the OAuth 2.0 protocol” と説明する。

フローは OAuth の認可コードフローをそのまま使い、scope=openid を足すと id_token が返るようになる。別物ではなく拡張。

OIDC を有効にする唯一のスイッチは何か。

scopeopenid を含めること。 Core §3.1.2.1 が “Must contain the openid scope value”。

これが無ければ id_token は返らず、ただの OAuth 2.0 のフローになる。

ID トークンの REQUIRED なクレームを 5 つ挙げよ。

iss / sub / aud / exp / iat(Core §2)。

auth_time / nonce / acr / amr / azp / at_hash は OPTIONAL だが、 条件次第で実質必須になる(max_age を送れば auth_timenonce を送れば nonce)。

`nonce` は必須か。フローごとに答えよ。
  • 認可コードフロー(response_type=code): OPTIONAL
  • implicit フロー: REQUIRED
  • hybrid フロー(code id_token / code id_token token): REQUIRED

「常に必須」は誤り。ただし認可コードフローでも送る — 送って初めて id_tokennonce 照合が MUST(Core §3.1.3.7 ステップ 11)になるため。

PKCE があれば `nonce` は不要か。

不要にはならない。 RFC 9700 §2.1.1 が代替を認めているのは “confidential OpenID Connect clients” だけで、しかも “With additional precautions” という条件が付く。

そもそも防ぐ対象が違う。PKCE は認可コードの傍受・注入、nonceid_token のリプレイ・注入。 検証する側も違う(PKCE は OP、nonce は RP)。

`id_token` を API のアクセス制御に使ってよいか。

使ってはいけない。 aud は “the OAuth 2.0 client_id”(Core §2)で、 宛先がクライアント自身。リソースサーバー向けの認可トークンとして設計されていない。

API に送るのは access token。id_token は「誰がログインしたか」を クライアントが確認するための値で、検証したら捨てる。

ID トークンの検証で、署名検証以外に MUST とされている項目を 3 つ挙げよ。

Core §3.1.3.7 より:

  1. iss が OP の Issuer 識別子と完全一致する(ステップ 2)
  2. aud に自分の client_id が含まれる(ステップ 3)
  3. 現在時刻が exp より前(ステップ 9)

nonce を送った場合はその照合(ステップ 11)も MUST。 署名検証は 13 ステップのうちの 1 つに過ぎない。

`at_hash` はどう計算するか。

access_token の ASCII 表現を、id_tokenalg に対応するハッシュ関数で ハッシュし、左半分だけを base64url する(Core §3.1.3.8)。

RS256 / HS256 なら SHA-256(32 バイト)の左 16 バイト

ターミナルウィンドウ
printf '%s' '2YotnFZFEjr1zCsicMWpAA' | openssl dgst -binary -sha256 | head -c 16 \
| base64 | tr '+/' '-_' | tr -d '='
# bJYTDxMKsNbRWDl-JNK8wQ

同じ計算を code に対してやったものが c_hash(hybrid フローで REQUIRED)。

利用者を一意に識別する主キーは何にすべきか。

iss + sub の組。 Core §2 の sub の定義は “A locally unique and never reassigned identifier within the Issuer” — 一意なのは発行者の中だけ

複数の OP に対応すると sub は衝突しうる。email はもっと悪い (変わる、email_verified: false がありうる)。

`public` と `pairwise` の subject type の違いは。
  • public — “The same sub value is returned to all clients”(§8.1)
  • pairwise — “A different sub value is returned to each client”

pairwise複数の RP が結託しても同一人物だと突き合わせられないことを保証する。 一般公開の IdP は pairwise、社内 SSO は public を選ぶ。

後から変えると全員の sub が変わるので、最初に決める。

UserInfo の応答で必ず検証すべきことは何か。

subid_tokensub と完全一致すること。 一致しなければ応答を使ってはならない(MUST)。

理由は access token が Bearer トークンだから。 別人の access token を握らされると、UserInfo はその別人の情報を返す (token substitution attack)。

OIDC の discovery パスは RFC 8414 と同じか。

違う。挿入位置が逆。 Issuer が https://as.example.com/tenant1 のとき:

  • OIDC Discoveryhttps://as.example.com/tenant1/.well-known/openid-configuration(後ろに連結)
  • RFC 8414https://as.example.com/.well-known/oauth-authorization-server/tenant1(ホストとパスの間)

Issuer にパスが無ければ結果は同じなので、マルチテナントを扱うまで気づかない。

Discovery で取得した設定について、必ず検証すべきことは。

issuer の値が、取得に使った URL の prefix と完全一致すること(Discovery §4.3)。

これを飛ばすと、攻撃者のエンドポイントが "issuer": "https://as.example.com" と 名乗るだけで正規の OP として登録されてしまう。

Front-Channel Logout と Back-Channel Logout の違いと、新規に選ぶべき方を答えよ。
  • Front-Channel — OP が各 RP の frontchannel_logout_uriiframe で並べたページを返し、ブラウザ経由で通知する
  • Back-Channel — OP が RP の backchannel_logout_uri直接 POST し、logout_token(JWT)を渡す

新規なら Back-Channel。 Front-Channel はサードパーティ Cookie が ブロックされると RP のセッション Cookie に届かず、失敗したことも分からない (この限界は仕様自身が明記している)。

`logout_token` が普通の `id_token` と違う点を挙げよ。
  • events クレームを持ち、値は http://schemas.openid.net/event/backchannel-logout
  • subsid のどちらかを必ず持つ(両方でもよい)
  • nonce クレームは禁止id_token と取り違えて認証に使われるのを防ぐため
`exp` と `auth_time` の違いを説明せよ。
  • expID トークン自体の有効期限。数分。過ぎたら捨てるだけ
  • auth_time利用者が実際に認証された時刻。セッションの鮮度を測る

「30 分以上前のログインなら再認証させたい」は auth_time で判断する (max_age=1800 を送って要求する)。混同すると毎分ログインし直させる実装になる。

`id_token` の `alg` はどう扱うべきか。

期待する alg を自分のコードに固定し、トークンの alg に従って分岐しない。 Core §3.1.3.7 ステップ 7 が、登録した id_token_signed_response_alg (既定 RS256)と一致するかを見るよう定めている。

トークンの alg を信じると、RS256 を期待する検証器に HS256 の トークンを投げられ、公開鍵が HMAC の共有鍵として使われる (アルゴリズム混同 — Bearer トークン参照)。