OpenID ConnectのIDトークンの内容と検証
authOpenID Connectは認可 (AuthoriZation) のプロトコルであるOAuth 2.0を正しく認証 (AutheNtication) に使うためのプロトコル。
OpenID Connect では OAuth のアクセストークンに加えて Issuer (IdP) によって署名されたJWT (JSON Web Token) 形式のIDトークンも返す。 このIDトークンの署名を検証し、含まれる Issuer とクライアントの情報を参照することで OAuth の Implicit flow でのトークン置き換え攻撃を防ぐことができる。
JWT/IDトークン
JWT は RFC7519 で定義されている、パーティ間で安全に Claim (エンドユーザーのようなエンティティの情報) を受け渡すための表現方法。 JSON にエンコードした Claim は、JOSE (Javascript Object Signing and Encryption) のサブセットである JWS (JSON Web Signature) のペイロードとして署名を付与されるか、JWE(JSON Web Encryption) で暗号化されて渡されることになっているが、 多くのケースで JWS が選択されている。
JWSには (ヘッダ).(ペイロード).(署名) の文字列で表現される Compact Serialization と、JSON で表現される JSON Serialization があるが、JWTでは Compact Serialization を用いる。
ヘッダには署名に使うアルゴリズムalgが含まれ、JWTを受け取った際、不正なalgになっていないかチェックする必要がある。
{
"alg": "RS256",
"kid": "5b0924f6f83c719514987954cf66683b370677d4"
}
ペイロードには以下のような Claim が含まれ、任意のフィールドを追加することができる。
{
"iss": "https://server.example.com", # IssuerのIdentifier httpsのURL
"sub": "24400320", # Subject Identifier Issuerでユニークなエンドユーザーの識別子
"aud": "s6BhdRkqt3", # audience OAuth2.0のclient_id
"nonce": "n-0S6_WzA2Mj", # リクエストで送った値がそのまま返ってくる リプレイ攻撃を防ぐためもの
"exp": 1311281970, # IDトークンの有効期限 時間はすべてUNIXエポック秒
"iat": 1311280970, # IDトークンの発行時刻
"auth_time": 1311280969 # エンドユーザーの認証時刻
}
IDトークンを取得する
Googleの OAuth 2.0 API は OpenID Connect に対応している。これの IDトークン を取得してみる。
エンドポイント等は OpenID Connect Discovery 1.0 の /.well-known/openid-configuration で取得できるようになっている。
$ curl https://accounts.google.com/.well-known/openid-configuration | jq
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint": "https://www.googleapis.com/oauth2/v4/token",
"userinfo_endpoint": "https://www.googleapis.com/oauth2/v3/userinfo",
"revocation_endpoint": "https://accounts.google.com/o/oauth2/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"locale",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
]
}
テスト用にcodeを受け取ってトークンをリクエストするサーバーを書いた。コードはGitHubにある。 client_id と client_secret は API Console で発行できる。
$ curl https://localhost:3000/auth
{
"code": {
"state": "*****",
"code": "*****",
"authuser": "0",
"session_state": "*****",
"prompt": "none"
},
"token": {
"access_token": "*****.*****.*****"
},
"id_token_header": {
"alg": "RS256",
"kid": "5b0924f6f83c719514987954cf66683b370677d4"
},
"id_token_payload": {
"azp": "*****",
"aud": "*****",
"sub": "*****",
"email": "****@gmail.com",
"email_verified": true,
"at_hash": "*****",
"nonce": "*****",
"iss": "https://accounts.google.com",
"iat": 1506613038,
"exp": 1506616638
},
"id_token_verify_signature": "*****"
}
このIDトークンの署名を検証し、 iss, audを見て発行元と発行先が正しいことを確認してexpも過ぎていなければsubに示されるIDのエンティティとして認証できる。
IDトークンの署名を検証する
検証もやってみる。
GoogleのIDトークンのalgを見ると、RS256(RSASSA-PKCS1-v1_5 using SHA-256) で署名されていることがわかる。署名対象のデータはJWSの (ヘッダ).(ペイロード) まで。
RSA暗号とPEM/DERの構造 - sambaiz-net
公開鍵は Discovery の jwks_uri で取得できる。これは1日に1回更新される。
$ curl https://www.googleapis.com/oauth2/v3/certs
{
"keys": [
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "23e255c65b234549cc0fe3073bce15e59bd4d4b0",
"n": "w5i-jGiwEyuPewnvR-lFceBRYh4gx91-OFLaJwwr8yCrSVczAgyc1wywFBCsUBDBhHpKSVilqIGG2fIqhdX2_IFJ-OxYvXDmJtYF69kWTafZjFtnAl8EdIqj1X-y31Pm9gYD_rYeLG3CZhNLjIE_y9fk5_MbOOc0Z-br4_wzing6HfERITbAOAfCd8Ri0_tXDqYgi-C1C_gs2HheYEIWqpZ2se8UsGvIg2uePOCV8G3a0fuvh6hgjutspfJ_VH3eeHwYwyYzieq-sDWcyV5qGlnJp9TZlZ9z242WdYHj3C2kudNTUg76p6svbs6cu1ZiZA9WZkaL9d8hWeJ4tLQg3Q",
"e": "AQAB"
},
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "db15c5e7c1b82b93388459602e4852bfd9b95931",
"n": "lZUcUSL9piIsbwP_Y84683P7-vX_Y9CEvqpeCNpI4p55HFCDnp9xtnvc5mBEOrFP-vwk6sjlkLVbl74d1CR-jKX-z8zPg3T0qQzYWgedAddfQL1zFUyo2BLbCg2JeYDZF6IHv6qfwzM3hgQIMJMa29izyAyZ2T0zhXf5fU311LEKWCdpemQsNj5V4r5Z52vsTuOhm16Xt7LWx_iWb-_VdYxhDYoQ87pZIVaCdnKDwGON0MPoI4eQJdb-ABrcz290mbGJ8kiI4BU_iA98HCc3ifWDe8eatpV9LK54eYansDTMQJXoYZ6a7C-0-Mh1-g6qaxYjpymJXbJjYitiMejYFQ",
"e": "AQAB"
},
...
]
}
signature,n,eをそれぞれbase64デコードしたのを整数として扱い、m ≡ (signature)^e (mod n) で複合すると digestInfoのDERの前に 00 01 ff ff .. 00 のパディングがついたものになる。
digestInfo ::= SEQUENCE {
digestAlgorithm DigestAlgorithmIdentifier,
digest Digest
}
Digest ::= OCTET STRING
const digestInfoDERFromSignature = (signature, e, n) => {
const signatureHex = Buffer.from(signature, 'base64').toString('hex')
const eHex = Buffer.from(e, 'base64').toString('hex')
const nHex = Buffer.from(n, 'base64').toString('hex')
const signatureNum = bigInt(signatureHex, 16)
const eNum = bigInt(eHex, 16)
const nNum = bigInt(nHex, 16)
const m = signatureNum.modPow(eNum, nNum); // c^e (mod n)
const decrypted = m.toString(16);
const paddingRemoved = decrypted.replace(/^1f*00/g, "");
return paddingRemoved;
}
この中のdigestと (ヘッダ).(ペイロード) のsha256 hashが一致することを確認する。 digestは末尾にくるので簡易的にはendsWithで比較できる。
$ curl curl https://localhost:3000/verify?token=<token>
{
"ok": true,
"iss": "https://accounts.google.com",
"aud": "*****",
"exp": 1507551757,
"digestInfoDER": "*****",
"hash": "*****"
}