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でリクエストを作る。
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も変わっているみたいだし、まだまだわからんことばかりである。