はじめに
今回で第4回になりますが、前回まではOpenAMを利用してOAuth2やOpenID Connectが実際に動作する環境を作成して、サービスとの認証連携について触れてきました。
今回は実際にOpenID Connectを利用してサービスでユーザーの特定や情報を取得しようとしたときに、
抑えておかなければならない知識について、IDトークン(JSON Web Token)を解明しながら、
また、実装されている内容も交えながら説明していきたいと思います。
OAuthやOpenID Connectにおける基礎的な事柄は詳しく説明していないので、他サイトなどの情報も参考にしながら読んでください。
また、第1回から第3回までの内容を踏まえておくとより理解が進むかと思いますので、下記も参考にしてください。
OAuth2.0/OpenID Connectの利用(1) : ソーシャルログインでOpenAMにユーザ認証してみる(Facebook編)
OAuth2.0/OpenID Connectの利用(2) : ソーシャルログインでOpenAMにユーザ認証してみる(Google編)
OAuth2.0/OpenID Connectの利用(3) : OpenAMでOPを立ててmod_auth_openidcと連携する
IDトークンとは
OpenID Connectの特徴のひとつとして、IDトークンがOPから発行されます。
これはOAuth2との違いであり、OAuth2ではユーザー個人の情報を連携するものではなく、
APIの許可を発行することに限るため、IDトークンは発行されません。
但し、OpenAMの実装でみるとscopeにopenidを指定しないでアクセストークンを取得して、
アクセストークンでユーザー情報取得のエンドポイントであるuserinfoにアクセスすれば、
subとscopeで指定したクレームを取得することが可能です。
しかし、後述するIDトークンを利用した検証手段がないので、様々な脆弱性対応がおろそかになる
ということを念頭においてください。
OpenID Connectで扱われるIDトークンはJSON Web Tokenとして定められています。
JSON Web Tokenのスペックは以下のURLを参照してください。
https://tools.ietf.org/html/rfc7519
また、OpenID Connectで扱われるクレームの種類については下記を参照してください。
https://openid.net/specs/openid-connect-core-1_0.html#IDToken
略称としてJWTと記載できますが、推奨される発音は英単語の "jot" と同じです。
IDトークンをGETする
IDトークンを手元に取得して解明していこうと思いますが、どのタイミングでどこに送られるのかを知る必要があります。
そのため、OpenID Connectのフローについて少し説明しましょう。
大まかにフローとしては以下の4つがあります。
・Basic Client Profile
・Implicit Client Profile
・Hybrid Client Profile
・JWT Bearer Token Profile
このうちHybrid Client Profileはresponse_typeと呼ばれるレスポンスに含めてほしい認可コード、IDトークン、アクセストークンを複合して指定することが出来るタイプです。「code,id_token」,「code,id_token,token」のようにクライアントからOPにリクエストをするときにクエリパラメータとして含めます。
フローとしてはBasic Client ProfileかImplicit Client Profileか、はたまた要件に伴い変化するため、特に説明はしません。
JWT Bearer Token ProfileはJWTトークンをClientサイドで自己署名して、ダイレクトにOPにアクセストークンを要求するフローになります。
クライアントがトークンエンドポイントに対してJWTトークンをPOSTするフローになるので、特に説明はありません。
ということで、Basic Client ProfileとImplicit Client Profileについてフローを見ていきましょう。
Basic Client Profile
Basic Client Profileは上図のような流れになりますが、特徴としてはユーザー(リソースオーナー)がブラウザ等を介して、OPが表示する画面で認証や同意の許可をしたあとに、認可コードを受け取ってクライアント(RP)に渡します。
そのあとはクライアント(RP)が直接OPにアクセスしてIDトークンやアクセストークンを取得して、
必要に応じてアクセストークンでOP上のUserInfoエンドポイントからユーザー情報を取得します。
ポイントとしてはIDトークンをもらうのに2段階必要ということです。
はじめに認可コードを取得してから、IDトークンを受け取ります。
そしてIDトークンはクライアント(RP)が直接OPから受け取るため、ブラウザ上には表示されません。
このフローでIDトークンを取得したいときはクライアント(RP)のログや通信情報から取得する必要があります。
第3回で説明した以下の記事では、Apacheのエラーログから情報を参照できます。
[OAuth2.0/OpenID Connectの利用(3) : OpenAMでOPを立ててmod_auth_openidcと連携する]
<Location />
AuthType openid-connect
Require valid-user
LogLevel debug
</Location>
上記のようにLogLevel debugを追加して実行すると、以下のようなログが参照できます。
[Wed Nov 13 12:21:04.830988 2019] [auth_openidc:debug] [pid 14595] src/util.c(835):
[client 10.0.1.73:49865] oidc_util_http_call: response={"access_token":"7fcb52a8-
1597-4a0c-a0e5-5fb8edc2a452","scope":"openid profile
email","id_token":"eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbF
RrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJtcUV6bUx0S05IUWxvNVlobUU1VThnIiwgInN
1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVu
YW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICJlbUh0OWg1VzF6NGlpVUF2N
nR2MUZYRGpLeG9WT240djlLQ3NObDNuR2xRIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOi
Aidmw2QWNTYUdaUVI1UnZnT0NUaUVDQSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjN
iZGI4YWVmLWFlZTctNDRmYS1hNzFkLTk5MTI2OGNlODZlOCIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAi
YXV0aF90aW1lIjogMTU3MzYxNTI2OSwgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3MzYxODg3NCwgInRva
2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTczNjE1Mjc0IH0.W1OX2iO2A-
HGnbU01U2cTeJ1DEpUrxNEkTgj4U87KjUuBMewb8JXcUK5oehrGp6MfogbqyVT6Rfl3w4lrR_byUKz58gYxmJ
mu_TxS4Fjwe_kNpZHthLMxLKg8Kg0vckc2uLtLTFZVXIjSBQZ47d2djvi6ZwK1WhZkT97GxYXlaMEzbmhE6sb
nfQ-
FR6GqKqpFJcsX2Etb7hJckGSRGfihxicWlynnMLwfOXlPNmbWbqbF_OgeFlH9GtguRL4pzV88cr0eK4S_RWys
h0cgm42F-u1GWUv3Ifw-W3-3HZ5HQjoCydq561SZIgzrDtGBBEU7nhi1AV-
Ciz0Zy5EPSiXmA","token_type":"Bearer","expires_in":3599,"nonce":"emHt9h5W1z4iiUAv6tv1
FXDjKxoVOn4v9KCsNl3nGlQ"}, referer: https://oidcrp.test.osstech.co.jp/auth/
上記の"id_token":"eyAid....."の長い文字列がIDトークンになります。
Implicit Client Profile
Implicit Client Profileは上図のような流れになりますが、特徴としてはユーザー(リソースオーナー)がブラウザ等を介して、OPが表示する画面で認証や同意の許可をしたあとに、IDトークンやアクセストークンを受け取ってクライアントに渡します。
Basic Client Profileと異なり、認可コードが登場せず、クライアント(RP)はブラウザ等を介してIDトークンやアクセストークンを受け取ることになります。
このフローでIDトークンを取得したいときはブラウザの通信情報から取得できます。
ちなみにこのフローの中でRPから送信されたnonceが過去に使われていないかをOP側でチェックするところですが、先に紹介した仕様のなかでもSHOULDとされていますが、OpenAMでは実際には実装されていません。
MUSTであるのはIDトークンで戻ってきたものをクライアント(RP)がチェックするところです。
第3回で説明したmod_auth_openidcとの連携でこのフローを利用して、
ブラウザのツール等で見るとOPからのリダイレクト時に以下のようにクエリパラメータが付与されています。
Basic Client ProfileとImplicit Client Profileのフローについての説明をしてきましたが、
ポイントとして両者はクラインアントのクレデンシャルを安全に管理できるかどうかの違いがあります。
第3回で説明したmod_auth_openidcとの連携ではサーバー上にクライアントがあるので、
管理者でなければクレデンシャルを参照できません。
しかし、ネイティブアプリやJavaScriptなどで実装されたクライアントの場合は、
解析される可能性があるため、クレデンシャルを安全に管理するのは困難です。
そのためBasic Client Profileの場合はトークンエンドポイントの認証にクライアントIDとクレデンシャルを利用するため(※1)、クライアントの確認ができます。
また、トークンやユーザー情報のやりとりもサーバー間での直接通信のため、漏洩する可能性が少ないです。
Basic Client Profileのほうがより安全といえるでしょう。
ちなみにOpenAMでこの2つのフローの選択する箇所は、第3回の記事で下記に紹介した章の
クライアント(RP)設定のレスポンスタイプの項目です。
https://qiita.com/kurotsu/items/aedc92af2e4f61493c22#mod_auth_openidc%E3%81%AE%E8%A8%AD%E5%AE%9A
mod_auth_openidcの場合はOIDCResponseTypeで指定しています。
"code"を設定すると、Basic Client Profileになり、"id_token token"など、"id_token"または "token"のいずれかが含まれていると、Implicit Client Profileのフローになります。
また、scopeとresponnse_typeの指定で各エンドポイントで発行される認可コード、アクセストークン、IDトークンが変わってきます。
IDトークンをデコードする
IDトークンの取得が出来たので次は実際に中身をデコードして確認していきましょう。
前章でmod_auth_openidcが出力したログに下記内容のIDトークンが出力されていました。
"id_token":"eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbF
RrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJtcUV6bUx0S05IUWxvNVlobUU1VThnIiwgInN
1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVu
YW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICJlbUh0OWg1VzF6NGlpVUF2N
nR2MUZYRGpLeG9WT240djlLQ3NObDNuR2xRIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOi
Aidmw2QWNTYUdaUVI1UnZnT0NUaUVDQSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjN
iZGI4YWVmLWFlZTctNDRmYS1hNzFkLTk5MTI2OGNlODZlOCIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAi
YXV0aF90aW1lIjogMTU3MzYxNTI2OSwgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3MzYxODg3NCwgInRva
2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTczNjE1Mjc0IH0.W1OX2iO2A-
HGnbU01U2cTeJ1DEpUrxNEkTgj4U87KjUuBMewb8JXcUK5oehrGp6MfogbqyVT6Rfl3w4lrR_byUKz58gYxmJ
mu_TxS4Fjwe_kNpZHthLMxLKg8Kg0vckc2uLtLTFZVXIjSBQZ47d2djvi6ZwK1WhZkT97GxYXlaMEzbmhE6sb
nfQ-
FR6GqKqpFJcsX2Etb7hJckGSRGfihxicWlynnMLwfOXlPNmbWbqbF_OgeFlH9GtguRL4pzV88cr0eK4S_RWys
h0cgm42F-u1GWUv3Ifw-W3-3HZ5HQjoCydq561SZIgzrDtGBBEU7nhi1AV-
Ciz0Zy5EPSiXmA"
このトークンの文字列はJSON Web Tokenになります。JSON Web TokenはBase64URLエンコーディングされており、また.(ピリオド)で3つの部分に区切られています。
3つの部分は以下の内容です。
・JWTヘッダ
・JWTクレーム
・署名
JSON Web Tokenをデコードしてみましょう。
今回Javaのコードでデコードしてみました。Java8からはjava.util.Base64にBase64URLデコードのメソッドがあります。
package devTest;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JWTDecode {
public static void main(String[] args) {
String idToken = "eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbFRrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJtcUV6bUx0S05IUWxvNVlobUU1VThnIiwgInN1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVuYW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICJlbUh0OWg1VzF6NGlpVUF2NnR2MUZYRGpLeG9WT240djlLQ3NObDNuR2xRIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOiAidmw2QWNTYUdaUVI1UnZnT0NUaUVDQSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjNiZGI4YWVmLWFlZTctNDRmYS1hNzFkLTk5MTI2OGNlODZlOCIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAiYXV0aF90aW1lIjogMTU3MzYxNTI2OSwgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3MzYxODg3NCwgInRva2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTczNjE1Mjc0IH0.W1OX2iO2A-HGnbU01U2cTeJ1DEpUrxNEkTgj4U87KjUuBMewb8JXcUK5oehrGp6MfogbqyVT6Rfl3w4lrR_byUKz58gYxmJmu_TxS4Fjwe_kNpZHthLMxLKg8Kg0vckc2uLtLTFZVXIjSBQZ47d2djvi6ZwK1WhZkT97GxYXlaMEzbmhE6sbnfQ-FR6GqKqpFJcsX2Etb7hJckGSRGfihxicWlynnMLwfOXlPNmbWbqbF_OgeFlH9GtguRL4pzV88cr0eK4S_RWysh0cgm42F-u1GWUv3Ifw-W3-3HZ5HQjoCydq561SZIgzrDtGBBEU7nhi1AV-Ciz0Zy5EPSiXmA";
byte[] bytes = null;
Charset charset = StandardCharsets.UTF_8;
try {
String[] tokens = idToken.split("\\.");
for (int i = 0; i < tokens.length; i++) {
System.out.println("### " + String.valueOf(i+1) + ". ###");
if(i < 2) {
bytes = Base64.getUrlDecoder().decode(tokens[i].getBytes());
System.out.println(new String(bytes, charset));
} else {
System.out.println(tokens[i]);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
実行すると、以下の表示が得られました。
### 1. ###
{ "typ": "JWT", "kid": "aWBkELbhmjAYv95mhdHZF5vXlTk=", "alg": "RS256" }
### 2. ###
{ "at_hash": "mqEzmLtKNHQlo5YhmE5U8g", "sub": "osstech1", "iss": "https://sso.test.osstech.co.jp:443/openam/oauth2", "tokenName": "id_token", "nonce": "emHt9h5W1z4iiUAv6tv1FXDjKxoVOn4v9KCsNl3nGlQ", "aud": "modauthopenidc", "c_hash": "vl6AcSaGZQR5RvgOCTiECA", "org.forgerock.openidconnect.ops": "3bdb8aef-aee7-44fa-a71d-991268ce86e8", "azp": "modauthopenidc", "auth_time": 1573615269, "realm": "/usr", "exp": 1573618874, "tokenType": "JWTToken", "iat": 1573615274 }
### 3. ###
W1OX2iO2A-HGnbU01U2cTeJ1DEpUrxNEkTgj4U87KjUuBMewb8JXcUK5oehrGp6MfogbqyVT6Rfl3w4lrR_byUKz58gYxmJmu_TxS4Fjwe_kNpZHthLMxLKg8Kg0vckc2uLtLTFZVXIjSBQZ47d2djvi6ZwK1WhZkT97GxYXlaMEzbmhE6sbnfQ-FR6GqKqpFJcsX2Etb7hJckGSRGfihxicWlynnMLwfOXlPNmbWbqbF_OgeFlH9GtguRL4pzV88cr0eK4S_RWysh0cgm42F-u1GWUv3Ifw-W3-3HZ5HQjoCydq561SZIgzrDtGBBEU7nhi1AV-Ciz0Zy5EPSiXmA
それぞれの内容を見ていきましょう。
JWTヘッダ
{
"typ": "JWT",
"kid": "aWBkELbhmjAYv95mhdHZF5vXlTk=",
"alg": "RS256"
}
表示されているヘッダーは下記表の内容となっています。
ヘッダー名 | 内容 |
---|---|
typ | "JWT"もしくは"urn:ietf:params:oauth:token-type:jwt"を指定。単にJWTを表しているだけ。任意のパラメータ |
kid | 署名の検証に使用されるキーの識別子。 HS256等の共通鍵方式では含まれない。 |
alg | 署名に使用されたアルゴリズムを表す。 例) HS256:HMAC using SHA-256 RS256:RSASSA-PKCS1-v1_5 using SHA-256 ES256:ECDSA using P-256 and SHA-256 |
アルゴリズムについて補足すると、HMACは共通鍵方式です。共通鍵にはクライアントクレデンシャルが利用されます。他は公開鍵方式で、公開鍵はOPが提示したJSON Web Key URLから公開鍵を取得します。
この内容についてはJSON Web Algorithms (JWA)として下記にスペックが記載されています。
https://tools.ietf.org/html/rfc7518
ちなみに弊社製品のOpenAM13でサポートされているのは、HS256,HS384,HS512,RS256で
OpenAM14でサポートされているのは、上記に加えてES256,ES384,ES512です。
※2023/12現在まだサポートされていません。
JWTクレーム
{
"at_hash": "mqEzmLtKNHQlo5YhmE5U8g",
"sub": "osstech1",
"iss": "https://sso.test.osstech.co.jp:443/openam/oauth2",
"tokenName": "id_token",
"nonce": "emHt9h5W1z4iiUAv6tv1FXDjKxoVOn4v9KCsNl3nGlQ",
"aud": "modauthopenidc",
"c_hash": "vl6AcSaGZQR5RvgOCTiECA",
"org.forgerock.openidconnect.ops": "3bdb8aef-aee7-44fa-a71d-991268ce86e8",
"azp": "modauthopenidc",
"auth_time": 1573615269,
"realm": "/usr",
"exp": 1573618874,
"tokenType": "JWTToken",
"iat": 1573615274
}
表示されているクレームは下記表の内容となっています。
Public Claims
クレーム名 | 要否 | 内容 |
---|---|---|
sub | REQUIRED | ユーザ識別子 |
aud | REQUIRED | JWTを利用するアプリケーションやサービスの識別子。ここではクライアントID |
iss | REQUIRED | 発行者。ここではOpenAMサーバのURI |
exp | REQUIRED | JWTトークンの有効期限でUTC 1970-01-01T0:0:0Z から該当時刻までの秒数 |
iat | REQUIRED | JWTトークンの発行日時でUTC 1970-01-01T0:0:0Z から該当時刻までの秒数 |
azp | OPTIONAL | 認証したパーティ。ここではクライアントID |
auth_time | OPTIONAL or REQUIRED | auth_timeは認証日時でUTC 1970-01-01T0:0:0Z から該当時刻までの秒数。リクエストにmax_ageが含まれている場合REQUIRED |
at_hash | OPTIONAL | アクセストークンを元に計算したハッシュ値 |
nonce | OPTIONAL or REQUIRED | リプレイアタック防止用のパラメータとともにクライアントのセッションパラメータとしても利用される。認証リクエスト時にnonceが含まれている場合、そのままの値を設定する。 認可サーバで同じnonceを処理していないかチェックする(SHOULD) クライアントで送信したものと同じかチェックする(MUST) |
acr | OPTIONAL | 認証コンテキストクラスリファレンス。認証時利用されたコンテキストクラスの値 |
amr | OPTIONAL | 認証メソッドリファレンス。配列になっている。たとえばOTP,passwordなど |
Private Claims
クレーム名 | 内容 |
---|---|
realm | OpenAMのレルム |
tokenType | トークンの種類 |
tokenName | response_typeで指定されたトークン名 |
org.forgerock.openidconnect.ops | 認証トークンのリンク |
auditTrackingId | 監査ログの追跡ID |
auditTrackingId | 監査ログの追跡ID |
Standard Claims
標準クレームについては下記を参照してください。
https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#StandardClaims
この標準クレームをユーザー情報として取得するには、Authorizationリクエスト時のscopeに指定する必要があります。
例えば、scope=openid%emailとした場合にemailのスコープに紐づくクレームが追加されます。
emailのスコープにはemailおよびemail_verifiedがクレームとして返却されるといった具合です。
スコープとクレームの紐づけとしては下記を参照してください。
https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#ScopeClaims
この標準クレームはUserInfoエンドポイントへアクセストークンを使ってリクエストしてJSONの形式で返却されます。
IDトークンのJson Web Token内に含めて渡されるケースもあります。
Authorizationリクエスト時のresponse_typeに「id_token」のみ指定した場合です。
認可コードやアクセストークンを取得することがないので、UserInfoエンドポイントにアクセストークンを渡すことが出来ないからです。
弊社製品のOpenAM13以降ではカスタムスコープとカスタムクレームを「OIDC Claims Script」という機能で簡単に追加することが可能です。
署名
W1OX2iO2A-HGnbU01U2cTeJ1DEpUrxNEkTgj4U87KjUuBMewb8JXcUK5oehrGp6MfogbqyVT6Rfl3w4lrR_byUKz58gYxmJmu_TxS4Fjwe_kNpZHthLMxLKg8Kg0vckc2uLtLTFZVXIjSBQZ47d2djvi6ZwK1WhZkT97GxYXlaMEzbmhE6sbnfQ-FR6GqKqpFJcsX2Etb7hJckGSRGfihxicWlynnMLwfOXlPNmbWbqbF_OgeFlH9GtguRL4pzV88cr0eK4S_RWysh0cgm42F-u1GWUv3Ifw-W3-3HZ5HQjoCydq561SZIgzrDtGBBEU7nhi1AV-Ciz0Zy5EPSiXmA
JSON Web Tokenの3つ目の部分が上記の署名文字列です。
JWTヘッダとJWTクレームをピリオド連結した内容を 鍵とアルゴリズムにより算出されたHash文字列を Base64URLエンコーディングされた値です。
アルゴリズムは先に述べたようにJWTヘッダのalgに指定されています。
鍵の取得はアルゴリズムがHS256,HS384,HS512であれば、クライアント側にも設定するクライアントクレデンシャルを使います。
それ以外であれば、公開鍵を取得する必要がありますがOpenID Connectにはjwks_uriという公開鍵を参照するURIを用意しています。
jwks_uriのURIはどこから取得するかというと、OPのメタデータに指定されています。
OpenAMのメタデータはデフォルトで下記URLです。第3回でもmod_auth_openidcのOIDCProviderMetadataURLという項目に設定にしました。
https://[OpenAMサーバのFQDN]/openam/oauth2/.well-known/openid-configuration
実際にアクセスしてみましょう。
JSON形式となっていて、OPの各エンドポイントや各サポート状況がわかるようになっています。
公開鍵は複数ある可能性があるため、JWTヘッダーのkidが一致する鍵を選択します。
そして署名の生成には"n"と"e"の値を使っています。
ちなみに第2回で紹介してGoogleについても下記のURLにメタデータがあり、jwks_uriが規定されていますので、参照してみてください。
https://accounts.google.com/.well-known/openid-configuration
https://www.googleapis.com/oauth2/v3/certs
IDトークンの検証
OpenID Connectで規定されているJSON Web Tokenの検証項目について説明します。
署名検証
クライアントは取得したJSON Web Tokenの署名について、発行元の鍵を利用して検証します(MUST)。
但し、クライアントがトークンエンドポイントとTLSでの直接通信でJSON Web Tokenを取得した場合はTLSサーバの発行元検証を代わりに使用してもよい(MAY)。
クレーム検証
クレーム検証の内容は下記表にまとめました。
クレーム名 | 要否 | 内容 |
---|---|---|
alg | SHOULD | JWTヘッダーのalgクレームはRS256(デフォルト)であるか、OP上のクライアント登録時のパラメータであるid_token_signed_response_algと同じであるか検証する |
iss | MUST | OPの発行者識別子と同じであるか検証する |
aud | MUST | 自身のクライアントIDが含まれているか検証する。配列として複数のIDがある場合があるので信頼できないaudienceがある場合は拒否する必要がある。 |
azp | SHOULD | audが複数値の場合、このクレームが存在することと自身のクライアントIDが設定されているか検証する |
exp | MUST | expの値は現在時刻よりも前である必要がある |
iat | OPTIONAL | このクレームにIDトークンの発行日時が設定されているが、クライアント側で定義した期限を超えたら破棄してよい。これによりnonceの保存期間も制限できる。 |
nonce | MUST | このnonceクレームが存在した場合は認証リクエストで送った値と同じか検証する。 |
acr | SHOULD | acrクレームをリクエストした場合はクライアントは適正な値か検証する。 |
auth_time | SHOULD | auth_timeクレームをリクエストした場合は大幅に時間が経過している場合は再認証を求める必要がある。また、認証リクエスト時にmax_ageパラメータを含めて許容時間を設定することもできる。 |
認可コードの検証
クライアントはIDトークンのc_hashクレームで認可コードを検証することが出来る(SHOULD)。
at_hashはJWTヘッダーのalgがRS256の場合はSHA-256でアクセストークンのハッシュ値を生成して、左半分の128ビットをbase64urlエンコードした値である。
アクセストークンの検証
クライアントはIDトークンのat_hashクレームでアクセストークンを検証することが出来る(SHOULD)。
at_hashはJWTヘッダーのalgがRS256の場合はSHA-256で認可コードのハッシュ値を生成して、左半分の128ビットをbase64urlエンコードした値である。
検証コードを実装してみる
では実際にIDトークンの検証を実装していきたいと思います。
auth0というプロジェクトのjava-jwtというライブラリを利用して、Javaのコードで検証します。
jwt.ioというサイトではさまざまな言語のライブラリが参照できます。
https://jwt.io
使用したパッケージを記載しておきますので、mavenリポジトリから取得してください。
package | dependencies | group id | artifact id | version |
---|---|---|---|---|
java-jwt-3.8.3.jar | compile | com.auth0 | java-jwt | 3.8.3 |
jwks-rsa-0.9.0.jar | compile | com.auth0 | jwks-rsa | 0.9.0 |
commons-io-2.6.jar | runtime | commons-io | commons-io | 2.6 |
commons-codec-1.12.jar | runtime | commons-codec | commons-codec | 1.12 |
guava-27.1-jre.jar | runtime | com.google.guava | guava | 27.1-jre |
jackson-annotations-2.10.0.pr3.jar | runtime | com.fasterxml.jackson.core | jackson-annotations | 2.10.0.pr3 |
jackson-core-2.10.0.pr3.jar | runtime | com.fasterxml.jackson.core | jackson-core | 2.10.0.pr3 |
jackson-databind-2.10.0.pr3.jar | runtime | com.fasterxml.jackson.core | jackson-databind | 2.10.0.pr3 |
まずは共通鍵方式のHS256(HMAC using SHA-256)で署名されたJson Web Tokeの検証コードです。
package devTest;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.net.URL;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwk.Jwk;
public class JWTValidate {
public static void main(String[] args) {
verifyTokenOfHmac256();
}
public static void verifyTokenOfHmac256(){
String hmac256_token = "eyAidHlwIjogIkpXVCIsICJhbGciOiAiSFMyNTYiIH0.eyAiYXRfaGFzaCI6ICJxSE1lamFacjI0WUsyMkpyV0pHMHh3IiwgInN1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVuYW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICJyT25zMXhGYlplLVdkQ1E1X2haN3pfZ3Y0b2xtRlZhdjBIYjF6S01tUkxVIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOiAiVFhzcnRYVWFrVkhiUmNRd0kwQmVKdyIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjFkYmU1ZDJhLTk3NzQtNGUzMi1iNTdlLTA3MmNlMWFhNGVjYiIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAiYXV0aF90aW1lIjogMTU3NDIzMzczNCwgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3NDIzNzMzNiwgInRva2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTc0MjMzNzM2IH0.9hR1Yg5jKzCVwJztwbcBw2Uo_eOcN2_5mwhc_F7PMwE";
try {
Algorithm algorithm = Algorithm.HMAC256("password");
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("https://sso.test.osstech.co.jp:443/openam/oauth2")
.withSubject("osstech1")
.withClaim("nonce", "rOns1xFbZe-WdCQ5_hZ7z_gv4olmFVav0Hb1zKMmRLU")
.withAudience("modauthopenidc")
.build();
DecodedJWT jwt = verifier.verify(hmac256_token);
System.out.println("Verify OK - Signature:" + jwt.getSignature());
} catch (JWTVerificationException e){
e.printStackTrace();
}
}
}
実行結果は下記のとおりです。
Verify OK - Signature:9hR1Yg5jKzCVwJztwbcBw2Uo_eOcN2_5mwhc_F7PMwE
上記コードではiss,sub,nonce,audのクレーム検証と署名検証を実施しています。
AlgorithmクラスのHMAC256メソッドに指定しているのがクライアントクレデンシャルです。
クレームの値をわざと間違えるとそれぞれのExceptionが発生します。
expについては指定していませんが自動で検証されます。
次に公開鍵方式のRS256(RSASSA-PKCS1-v1_5 using SHA-256)で署名されたJson Web Tokeの検証コードです。
package devTest;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.net.URL;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwk.Jwk;
public class JWTValidate {
public static void main(String[] args) {
verifyTokenOfRsa256();
}
public static void verifyTokenOfRsa256(){
String rsa256_token = "eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbFRrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJGZjNUTDh2ckJLZ2RCdk8yMF9uZHNRIiwgInN1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVuYW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICI5SjdiaDlRekZkbkxiOHlhS0hzb0VRaGRib19PSnlmSEhLZV9ybGJiWlV3IiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOiAiUTI4MlZPcW5ydEF3a0RwNlJHVHdzQSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogImU2NGVjODc1LWM3YzEtNDJiNS1iMDcyLWJiZDExZjA0NzhjMSIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAiYXV0aF90aW1lIjogMTU3NDIzODM3OCwgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3NDI0MTk4MSwgInRva2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTc0MjM4MzgxIH0.UBapQVw2UGdg1SSeNFTLaDujEzV7BzYKKnit3ef_b1T_GVVygzP8_eVVm7bRSkLGYLAd8ezxCsOi0lB9p2JK9cB-EcsMO5nr27yywcmswTW9qHOFuxvczKP7RPyWOH9JieE6AOy6cOShBzjAmo_v6L5j4ofmvOCXxTu14a0rGQExOgVpxOajzcUcWFHzxNgU9n_IF8gWT4ukNxbslNy_6veLCUr08Sj4Xh7vxVu8aGIKjTI2zXVbwZJjzM0WPjLcrakbY_Ea3nxsV6_aRZht5WVTLqN4VyTuw2TloCa32uF0Kv-X7JU8yA4kmVZQ1byERVabUej_zTl-wQxykoHEwA";
String jwkUrl = "https://sso.test.osstech.co.jp/openam/oauth2/connect/jwk_uri";
try {
DecodedJWT jwt = JWT.decode(rsa256_token);
String kid = jwt.getKeyId();
System.out.println("kid:" + kid);
UrlJwkProvider provider = new UrlJwkProvider (new URL(jwkUrl));
Jwk jwk = provider.get(kid);
RSAPublicKey rsaPublicKey = (RSAPublicKey)jwk.getPublicKey();
Algorithm algorithm = Algorithm.RSA256(rsaPublicKey, null);
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("https://sso.test.osstech.co.jp:443/openam/oauth2")
.withSubject("osstech1")
.withClaim("nonce", "9J7bh9QzFdnLb8yaKHsoEQhdbo_OJyfHHKe_rlbbZUw")
.withAudience("modauthopenidc")
.build();
verifier.verify(rsa256_token);
System.out.println("Verify OK - Signature:" + jwt.getSignature());
} catch (Exception e){
e.printStackTrace();
}
}
}
実行結果は下記のとおりです。
kid:aWBkELbhmjAYv95mhdHZF5vXlTk=
Verify OK - Signature:UBapQVw2UGdg1SSeNFTLaDujEzV7BzYKKnit3ef_b1T_GVVygzP8_eVVm7bRSkLGYLAd8ezxCsOi0lB9p2JK9cB-EcsMO5nr27yywcmswTW9qHOFuxvczKP7RPyWOH9JieE6AOy6cOShBzjAmo_v6L5j4ofmvOCXxTu14a0rGQExOgVpxOajzcUcWFHzxNgU9n_IF8gWT4ukNxbslNy_6veLCUr08Sj4Xh7vxVu8aGIKjTI2zXVbwZJjzM0WPjLcrakbY_Ea3nxsV6_aRZht5WVTLqN4VyTuw2TloCa32uF0Kv-X7JU8yA4kmVZQ1byERVabUej_zTl-wQxykoHEwA
これで検証の実装については説明しました。
必要に応じてクレーム検証の項目を選択してください。
参考までにアクセストークンと認可コードの検証コードも記載しておきます。
public static void verifyAccessToken(){
String id_token = "eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbFRrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJQQVNlaUw0aHk1WnpEWGh6X0wwR2FnIiwgInN1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVuYW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICI1bUFDNVFLS2ExZWt6QS1BMXh5d2RPMFRBaHRyYTdOcjFPSWFlOHNEdFVNIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOiAieVU2clBDMlVBNEo2Zzd3ZHJxemNrUSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjg3MzVhOGYxLWJhYjAtNGNhZC1iYWNjLWQ4MzdhN2YyMWIwYiIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAiYXV0aF90aW1lIjogMTU3NDMwNzY2NywgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3NDMxMTI2OSwgInRva2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTc0MzA3NjY5IH0.C_R3B4nSf8exAFKlF22juCMOFqcWcp1EnB8bi6pBiTACEuspgJPYCjVgg6egjXEbP0S4PV1lDyljuhOqcIfmBOxxNSeNxVQcM0VwXwzI_9Opw2LniI7pILqmIn6KGDNKoKU1CsaY_KbDrx8Q-13CNT0dPGpfQ6tTmouF1zzJU_UBtke5dk7oC7rOnoacSrkID1Sbzc3eDPbXJl4TLPMq8ymASITHYhI-S5vB40n7d7CURfn6Zj60OeokeYdeKVesJuCot2nhiL1Zvn67YGi7gC0vO81nqNrJRtaZmWCXwqpX8E06lxP_gfHwFd0BYUY9gYr2xiDj3hrzR5kfdlvAYg";
String access_token = "7da8f4b4-41a2-43e3-b06b-5bcbb3700ecd";
System.out.println("*** verifyAccessToken() ***");
try {
DecodedJWT jwt = JWT.decode(id_token);
Claim at_hash = jwt.getClaim("at_hash");
String strAtHash = at_hash.asString();
System.out.println(" at_hash:" + strAtHash);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashResult = digest.digest(access_token.getBytes());
byte[] hashLeftHalf = Arrays.copyOfRange(hashResult, 0, hashResult.length / 2);
byte[] encHashLeftHalf = Base64.getUrlEncoder().withoutPadding().encode(hashLeftHalf);
String strGenerateHash = new String(encHashLeftHalf);
System.out.println(" Generate access token Hash:" + strGenerateHash);
if(strAtHash.equals(strGenerateHash)) {
System.out.println("Verify OK - access Token");
} else {
System.out.println("Verify NG - access token");
}
} catch (Exception e){
e.printStackTrace();
}
}
public static void verifyAuthzCode(){
String id_token = "eyAidHlwIjogIkpXVCIsICJraWQiOiAiYVdCa0VMYmhtakFZdjk1bWhkSFpGNXZYbFRrPSIsICJhbGciOiAiUlMyNTYiIH0.eyAiYXRfaGFzaCI6ICJQQVNlaUw0aHk1WnpEWGh6X0wwR2FnIiwgInN1YiI6ICJvc3N0ZWNoMSIsICJpc3MiOiAiaHR0cHM6Ly9zc28udGVzdC5vc3N0ZWNoLmNvLmpwOjQ0My9vcGVuYW0vb2F1dGgyIiwgInRva2VuTmFtZSI6ICJpZF90b2tlbiIsICJub25jZSI6ICI1bUFDNVFLS2ExZWt6QS1BMXh5d2RPMFRBaHRyYTdOcjFPSWFlOHNEdFVNIiwgImF1ZCI6ICJtb2RhdXRob3BlbmlkYyIsICJjX2hhc2giOiAieVU2clBDMlVBNEo2Zzd3ZHJxemNrUSIsICJvcmcuZm9yZ2Vyb2NrLm9wZW5pZGNvbm5lY3Qub3BzIjogIjg3MzVhOGYxLWJhYjAtNGNhZC1iYWNjLWQ4MzdhN2YyMWIwYiIsICJhenAiOiAibW9kYXV0aG9wZW5pZGMiLCAiYXV0aF90aW1lIjogMTU3NDMwNzY2NywgInJlYWxtIjogIi91c3IiLCAiZXhwIjogMTU3NDMxMTI2OSwgInRva2VuVHlwZSI6ICJKV1RUb2tlbiIsICJpYXQiOiAxNTc0MzA3NjY5IH0.C_R3B4nSf8exAFKlF22juCMOFqcWcp1EnB8bi6pBiTACEuspgJPYCjVgg6egjXEbP0S4PV1lDyljuhOqcIfmBOxxNSeNxVQcM0VwXwzI_9Opw2LniI7pILqmIn6KGDNKoKU1CsaY_KbDrx8Q-13CNT0dPGpfQ6tTmouF1zzJU_UBtke5dk7oC7rOnoacSrkID1Sbzc3eDPbXJl4TLPMq8ymASITHYhI-S5vB40n7d7CURfn6Zj60OeokeYdeKVesJuCot2nhiL1Zvn67YGi7gC0vO81nqNrJRtaZmWCXwqpX8E06lxP_gfHwFd0BYUY9gYr2xiDj3hrzR5kfdlvAYg";
String code = "8549b085-3318-4bf2-b5f9-c18c15b71167";
System.out.println("*** verifyAuthzCode() ***");
try {
DecodedJWT jwt = JWT.decode(id_token);
Claim c_hash = jwt.getClaim("c_hash");
String strCHash = c_hash.asString();
System.out.println(" c_hash:" + strCHash);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashResult = digest.digest(code.getBytes());
byte[] hashLeftHalf = Arrays.copyOfRange(hashResult, 0, hashResult.length / 2);
byte[] encHashLeftHalf = Base64.getUrlEncoder().withoutPadding().encode(hashLeftHalf);
String strGenerateHash = new String(encHashLeftHalf);
System.out.println(" Generate authorization code Hash:" + strGenerateHash);
if(strCHash.equals(strGenerateHash)) {
System.out.println("Verify OK - authorization code");
} else {
System.out.println("Verify NG - authorization code");
}
} catch (Exception e){
e.printStackTrace();
}
}
実行結果は下記のとおりです。
*** verifyAccessToken() ***
at_hash:PASeiL4hy5ZzDXhz_L0Gag
Generate access token Hash:PASeiL4hy5ZzDXhz_L0Gag
Verify OK - access Token
*** verifyAuthzCode() ***
c_hash:yU6rPC2UA4J6g7wdrqzckQ
Generate authorization code Hash:yU6rPC2UA4J6g7wdrqzckQ
Verify OK - authorization code
おわりに
今回はIDトークン(JSON Web Token)を解明しながら、OpenID Connectの仕様について理解していく内容を記載しました。
クライアント側の実装の一助になれば幸いです。
全4回に渡ってOpenID Connectについての記事を掲載しましたがいかがでしたでしょうか。
また、新たに記事のネタが思いついたら投稿したいと思います。
追記
2020/03/04 Basic Client Profileの説明箇所で記載していなかったものとしてPKCEについての実装があります。
RFC7636 Proof Key for Code Exchange by OAuth Public Clients
OpenAMにもこの実装は対応しており、code_challenge,code_challenge_method,code_verifierのリクエストパラメータをClientと受け渡して、認可コードの横取りがされてしまったときにアクセストークンを悪意の第三者に渡ってしまうことを防止しています。