Azure ADを使ってOpenID Connect(OIDC)*1の「認可コードフロー」のクライアント(Webアプリ)側の流れを確認していく*2。
公式のドキュメントは以下。 https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-protocols-oidc
認可リクエストを要求する。
以下の認可サーバのエンドポイントに適切なパラメータを指定して、認可リクエストを要求する。
https://login.microsoftonline.com/common/oauth2/v2.0/authorize ?client_id=ZZZZ &response_type=code &response_mode=fragment &scope=openid &redirect_uri=http://localhost:3000/auth/callback &state=xyz &nonce=abc
(見やすさのため、改行を入れている。)
client_id=ZZZZ
アプリ登録時に表示されるクライアントID。 認可コードはこのクライアントID別に発行される。
クライアントIDは公開しても問題にはならない。
response_type=code
認可サーバに要求するresponseの種類を指定する。 ここでのcodeは、認可コードを要求することを示す。
response_mode=fragment
セキュリティの理由によりform_postが強く推奨されているが、 確認のため、今回はわかりやすいfragmentを指定した。
scope=openid
scopeは、ユーザーにリソースアクセスへの認可を求めるスコープ。 ちなみに、openidを指定しないとエラーになった。 Azure ADはOIDCの場合に限らずopenidの指定が必須?
redirect_uri=http://localhost:3000/auth/callback
このURLに認可サーバからリダイレクトされるURL。このURLに認可コード(response_typeで指定したデータ)が返される。 アプリ登録時に指定しておいたURLと一致する必要がある。
state=xyz
オプションだが、CSRF対策のために設定する。認証/認可リクエスト時にランダムな値を決める。
nonce=abc
認可コードフローでは別になくてもいいのかも。response_typeにid_tokenを指定した場合は必須になる。 認証/認可リクエスト時にランダムな値を決める。
認可コードを受け取る。
上記のURLにアクセスして、認可サーバでの認証が通ると、認可コードがリダイレクトで返ってくる。
http://localhost:3000/auth/callback?code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA&state=xyz&session_state=279c8370-69a2-4a97-8138-ac1711cb77b8
以下の部分が、認可コード。一部は省略している。
code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA
認可コードリクエストのときにstateを指定したので、認可コードと一緒にstateの値が返ってきている。 リダイレクトURLへのCSRF攻撃を防ぐため、認可コードリクエストのときに指定したstateと一致するstateが返ってきているかどうかを確認することが望ましい。
(バックエンドで)id_tokenを要求する。
リダイレクトで認可コードを受け取ったWebアプリは、バックエンドでid_tokenを要求する。
client_id=ZZZZ
認可コードの要求時と同様。
アプリ登録時に表示されるクライアントID。 認可コードはこのクライアントID別に発行される。
scope=openid
認可コードの要求時と同様。
client_secret=XXXXXX
アプリ毎に事前に発行する秘密鍵。アプリサーバ内で安全に管理する必要がある。値はダミー。
ちなみにSPAなど鍵を安全に管理できない場合は、クライアントシークレットの指定を省略したインプリシットフローがある。 インプリシットフローも無条件でクライアントシークレットを省略できるできるわけではなく、nonceの指定やid_tokenの証明書の検証が必須になったり、リフレッシュトークンが発行されないとか違いがある。
grant_type=authorization_code
認可コードフローの場合は、authorization_codeを指定する。 grant_typeってOAuthの言葉のはずなので少し気持ち悪いが、openidでもこれでよいのかな?
redirect_uri=http://localhost:3000/auth/callback
認可コードの要求時と同様の値を設定しておく。実施にリダイレクトはされない。
code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA
リダイレクトで受け取った認可コードを指定する。 認可コードは一度のみ有効なので、複数回同じ認可コードを使いまわそうとするとエラーが返ってくる。 また、認可コードの有効期限は一般的には数分程度と短く設定される。
curl -X POST -H "Content-Type: application/x-www-form-urlencoded" \ -d 'client_id=ZZZZ' \ -d 'scope=openid' \ -d 'client_secret=XXXXXX' \ -d 'grant_type=authorization_code' \ -d 'redirect_uri=http://localhost:3000/auth/callback' \ -d 'code=OAQABAAIAAAAm-.....-JlY3t76HnlOepcgAA' \ 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
すると、JSON形式でaccess_tokenとid_tokenが返ってきた。 scopeにはopenidしか指定していなかったはずだが、なぜかprofile, emailも含まれていた。
{ "token_type": "Bearer", "scope": "openid profile email", "expires_in": 3599, "ext_expires_in": 3599, "access_token": "eyJ0e.....XjlkbiJaZBw", "id_token": "ey.....J9.eyJhdWQiOiJ.....iI6IjIuMCJ9.mYN07gxEwVAyfJmHG2_.....__V26W6nzoVQ" }
token_typeのBearerとはtokenの保有のみで、誰がtokenを持っているかによらず、tokenの利用可否が決定されることを示す。
繰り返しになるが、このレスポンスはWebアプリがバックエンドで受け取るものである。 そのため、Webアプリ利用者がid_tokenやaccess_tokenの値を直接確認することはできない。 (確認されてしまうと問題。)
id_tokenの検証
id_tokenは署名付きのJWT(JSON Web Token)で構成される。JWTは、以下のようにヘッダとペイロードと署名がドットで結合されている。
(ヘッダ).(ペイロード).(署名)
この署名によって、id_tokenの正当性を証明できるようにしたところが、OIDCとOAuthの主な違いだという理解をしている。 (そのうえで、OIDCでは、nonceとかが追加されているのだが、認可コードフローの場合はnonceいらないかも?)
署名によって、JWTの改ざんの有無など検証できるのだが、認可コードフローの場合、httpsでid_tokenの通信経路が保護していれば、 id_tokenの発行元は信用する認可サーバであるので、署名の検証なしでもid_tokenのペイロードの内容だけ確認すれば認証を完了させることができる、と理解している。
ヘッダとペイロードはbase64エンコードされているので、それぞれ分割してbase64デコードすればそれぞれJSONが得られる。
ペイロード
検証したいペイロードの内容から確認していく。
echo 'eyJhdWQiOiJ.....iI6IjIuMCJ9' | base64 -d | jq
デコードとして、jqで整形すると以下のようなJSONが得られる。
{ "aud": "ZZZZ", "iss": "https://login.microsoftonline.com/YYYY/v2.0", "iat": 1592747582, "nbf": 1592747582, "exp": 1592751482, "nonce": "abc", "sub": "XXXX", "tid": "YYYY", "uti": "VbCxLeDmH0a68F1FTW15AA", "ver": "2.0" }
このJSONから以下について確認して問題がなければ認証完了できる。 確認する内容は以下の通りである。
- audは、アプリのクライアントID
- issは、id_token発行元のURL。
- iatは、JWTの発行時刻(UNIX タイムスタンプ)
- nbfは、JWTが有効となる時刻(基本的にはiatと同じ?)
- expは、JWTが有効期限切れとなる時刻(このトークンは3,900秒有効)
- nonceは、認可リクエスト時に指定したID。
- utiは、Azure AD内部で管理する値で、無視してよい。
- subは、ユーザを一意に識別するための値。認証にはこの値を使う必要がある。(ダミー)
- tidは、Azure ADのテナントID。(ダミー)
ヘッダ
認可サーバとの通信がhttpsでない場合は、JWTの署名を検証する必要がある。 あとは、また、認可サーバから直接id_tokenを受け取らないフローの場合も署名の検証は必要である。
JWTの署名を検証するための情報がヘッダにあるので、とりあえず、中身を見てみる。
echo 'ey.....J9' | base64 -d | jq
デコードした結果は以下。
{ "typ": "JWT", "alg": "RS256", "kid": "SsZsBNhZcF3Q9S4trpQBTByNRRI" }
algは署名のアルゴリズムでRS256が指定されている。RS256は公開鍵を使った署名。 kidは署名に使う公開鍵のThumbprint。公開鍵は複数用意されており定期的に変更されるので、kidでどの公開鍵を使ったかが明示されている。
公開鍵の一覧はJWK Set Documentで確認できる。、マルチテナント(common)の場合は以下のURLから確認できる。 https://login.microsoftonline.com/common/discovery/v2.0/keys
具体的には以下のようなJSONに複数の公開鍵が公開されており、kidが一致するnとeがJWTの署名を検証するための公開鍵に相当する。
{ "keys": [ { "kty": "RSA", "use": "sig", "kid": "SsZsBNhZcF3Q9S4trpQBTByNRRI", "x5t": "SsZsBNhZcF3Q9S4trpQBTByNRRI", "n": "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-.....B4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ", "e": "AQAB", "x5c": [ "MIIDBTCCAe2gAwIBAgIQWHw7h/...../O61G2dzpjzzBPqNP" ], "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0" }, .... ] }
nとeからいつもの公開鍵(pem)を作ってみる。 rsa-pem-from-mod-exptが使える。
const getPem = require('rsa-pem-from-mod-exp'); // n const modulus = "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-LPVzsMKOuIu7qQQbeytIA6P6HT9_iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB_bAIhvuBcQh9ePH2yEwS5reR-NrG1PsqzobnZZuigKCoDmuOb_UDx1DiVyNCbMBlEG7UzTQwLf5NP6HaRHx027URJeZvPAWY7zjHlSOuKoS_d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9zYfT74dvUSSrMSR-SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ"; // e const exponent = "AQAB"; // public key const cert = getPem(modulus, exponent); console.log(cert)
するといつものpemが出力される。
-----BEGIN RSA PUBLIC KEY----- MIIBCgKCAQEAuHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G+LPVzsMKOuIu7qQQbey tIA6P6HT9/iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB/bAIhvuBcQh9ePH2 yEwS5reR+NrG1PsqzobnZZuigKCoDmuOb/UDx1DiVyNCbMBlEG7UzTQwLf5NP6Ha RHx027URJeZvPAWY7zjHlSOuKoS/d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9 zYfT74dvUSSrMSR+SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftF GOG4x32vEzakArLPxAKwGvkvu0jToAyvSQIDAQAB -----END RSA PUBLIC KEY-----
JWTの署名検証
一通り確認してみたいので、さきほど作った公開鍵を使って、JSONの検証をしてみる。
const jwt = require('jsonwebtoken'); const getPem = require('rsa-pem-from-mod-exp'); // jwt const token = 'ey.....J9.eyJhdWQiOiJ.....iI6IjIuMCJ9.mYN07gxEwVAyfJmHG2_.....__V26W6nzoVQ'; // n const modulus = "uHPewhg4WC3eLVPkEFlj7RDtaKYWXCI5G-LPVzsMKOuIu7qQQbeytIA6P6HT9_iIRt8zNQvuw4P9vbNjgUCpI6vfZGsjk3XuCVoB_bAIhvuBcQh9ePH2yEwS5reR-NrG1PsqzobnZZuigKCoDmuOb_UDx1DiVyNCbMBlEG7UzTQwLf5NP6HaRHx027URJeZvPAWY7zjHlSOuKoS_d1yUveaBFIgZqPWLCg44ck4gvik45HsNVWT9zYfT74dvUSSrMSR-SHFT7Hy1XjbVXpHJHNNAXpPoGoWXTuc0BxMsB4cqjfJqoftFGOG4x32vEzakArLPxAKwGvkvu0jToAyvSQ"; // e const exponent = "AQAB"; // public key const cert = getPem(modulus, exponent); console.log(cert); jwt.verify(token, cert, function(err, decoded) { if (err) { console.log('error: ', err) return; } console.log('result: ', decoded) });
検証に成功すれば以下のような結果が返ってくる。
result: { aud: 'e02bd292-b5b6-462a-9278-780823262330', iss: 'https://login.microsoftonline.com/f54277c9-dafe-44aa-85a4-73d5c7c52450/v2.0', iat: 1592964084, nbf: 1592964084, exp: 1592967984, aio: 'ATQAy/8PAAAAXPCmIRlMDuwvMcHFlw8g4xw3aYMmi8/XqYKRffi3DbMDPtCK7ZcUvbd4OnnD1p4X', nonce: 'abc', sub: 'XXXX', tid: 'YYYY', uti: 'GXOYdmnjNk--8vGGqxtAAQ', ver: '2.0' }
このあと
とりあえず認証はできるようになったが、認証後、認証状態をどう管理するべきかは考えないといけなそう。 openidをそのままトークンとすることもできそうだが、sessionで管理してもよいのか?
最近、Cookieも変わっているみたいだし、まだまだわからんことばかりである。