こんにちは、富士榮です。
前回 に引き続きEntra IDの外部認証を試していきます。
こちらのドキュメントを見つつ設定をしていきますが、なかなかハマりポイントがあります。ちなみに外部認証は細かい動きを見るためにも自前のIdPを使っていきます。
ハマりポイントだけ先に書いておきます。
jwks_uriエンドポイントに公開するjwk(id_tokenの署名検証するための鍵)はx5cを含む必要がある id_tokenのJWTヘッダのメディアタイプは大文字"JWT”を指定する必要がある(昨日のポスト の通り) 外部認証プロバイダのdiscoveryとjwks_uriの情報をEntra ID側がキャッシュをするので変更があっても24時間は読みにきてくれない 外部認証プロバイダから返却するid_tokenの中のamrは配列で返す必要はあるが、返す値は単一である必要がある response_typeはid_token、reponse_modeはform_postで認証レスポンスを返却する必要がある(まぁ、この辺はいつものMicrosoftですね) 外部認証プロバイダの認可エンドポイントへ各種パラメータがPOSTされてくる(こちらは前回書いた通り)
とりあえず、こんな感じで動きます。ngrokでローカルで動かしているIdPを読みにこさせているので画面左側にtrafic inspectorを出しています。Entra IDでログインする際にリクエストが来ているのがわかります。
VIDEO
外部認証プロバイダの認可エンドポイントへPOSTされてくるデータなどをtrafic inspectorで確認しながら実装を進めていくのが良いと思います。
ということで、外部認証プロバイダを実装していきます。
まずは認可エンドポイントです。
こちらが認可エンドポイントへ投げ込まれてくるパラメータですので、こちらに対応する形でid_tokenを発行して返してあげれば良いはずです。
パラメータ 値 scope openid response_mode id_token client_id 外部認証プロバイダ側にEntra IDをRPとして登録した際のclient_idの値 redirect_uri https://login.microsoftonline.com/common/federation/externalauthprovider claims {"id_token":{"amr":{"essential":true,"values":["face","fido","fpt","hwk","iris","otp","tel","pop","retina","sc","sms","swk","vbm"]},"acr":{"essential":true,"values":["possessionorinherence"]}}}' nonce Entra IDが払い出すnonceの値 id_token_hint Entra ID側で認証済みのユーザに関する情報 client-request-id Entra ID側でのトラッキングに使う識別子(サポート用) state Entra ID側が払い出すstateの値
最終ゴールはid_tokenを生成し、リクエスト内のstateと合わせてredirect_uriへPOSTしてあげることとなります。
こんな感じでエンドポイントを作っていきましょう。
// 認可エンドポイント(POST)
router .post ("/authorize" , async (req , res ) => {
// Todo
// - redirect_uriが登録済みでEntra IDから提供されている規定値(https://login.microsoftonline.com/common/federation/externalauthprovider)であることの検証
// - client_idがEntra IDに割り当てたものであることの検証
// - id_token_hintの署名等の検証
// - ユーザ認証(id_token_hintに含まれるoid/tidを使ってユーザとの紐付け)
// - 認証応答を行う
// - redirect_uriへPOSTする
// - id_token
// - state : リクエストに含まれるstate(存在する場合)
// - id_tokenの中身
// - iss : idpのopenid-configurationで公開されているものと一致すること
// - aud : Entra IDに割り当てたclient_id
// - exp : 有効期限
// - iat : 発行時刻
// - sub : id_token_hintのsubと一致すること
// - nonce : リクエストに含まれるnonce
// - acr : リクエストのclaimsに含まれる値の一つと一致すること
// - amr : リクエストのclaimsに含まれる値と一致すること(配列)
結構やることはありますが、今回はまずはid_tokenを発行するところにフォーカスを当てますので、ユーザの認証や各種パラメータの検証は省略します。
id_tokenに含めるべき値にリクエスト内のid_token_hintに含まれる情報があるので、まずばid_token_hintのpayloadをデコードして値を取り出せる状態にパースします。
// とりあえずpayloadだけパースする(検証は後回し)
const id_token_hint_payload = req .body .id_token_hint .split ('.' );
const raw_id_token_hint = base64url .decode (id_token_hint_payload [1 ]);
const obj_id_token_hint = JSON .parse (raw_id_token_hint );
そしてid_tokenのpayloadを生成します。今回は検証もユーザ認証もしないので、acrやamrの値も決め打ちで設定しています。ちなみにハマりポイントにも記載した通り、amrは配列で値を設定する必要がありますが、値は単一でなければなりません。(今回はfidoを設定)
const date = new Date ();
const raw_id_token = {
iss : 'https://' + req .headers .host ,
aud : req .body .client_id ,
exp : Math .floor ((date .getTime () + (1000 * 60 * 10 )) / 1000 ),
iat : Math .floor (date .getTime () / 1000 ),
sub : obj_id_token_hint .sub ,
nonce : req .body .nonce ,
acr : 'possessionorinherence' ,
amr : ['fido' ]
};
そして、このpayloadを署名してJWTと作ります。
const id_token = await utils .generateJWS (raw_id_token );
こちらも面倒だったので、opensslで作った秘密鍵をベタ打ちでコードに埋め込んでいますし、kidも生成したものをベタで指定しています。この辺はおいおい直します。
const jwt = require ('jsonwebtoken' );
const privatekey = `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAmbjCIt20NKwMrH78TGOA8w9LS/R6B81RNHoJ/J+7UljRUfMN
sGC+RR6bqtDxeLgLjmt4s7CccbVf380DQx4b2XtCvoP0QW4GsJm7b13XkwCD6fWT
xSX5RTqXyTrLFk0ifOhRZ09QxZnAOGgUN12HeKjWj24XLQKFOROi8BhwHxLLSd+n
-- 省略 --
52EKdTgNOrVFGV/1qACGuDgLNDss+z2f2HgO/pk5UtS0EaXltv62IV6izkZh7f9O
aPXrS0BclfaGBZ0RcQIt0lJ2UMSTd8CFKX+k5efoFthX2ddWY24A
-----END RSA PRIVATE KEY-----`
exports .sign = async function (payload ){
return jwt .sign (payload , privatekey , {
algorithm : "RS256" ,
keyid : "Vzy3LDbuzSrt0cQldElZp5R92etQvOCENEu5aOOppYs"
});
}
あとはid_tokenとstateをPOSTしてあげるだけですが、node+express+ejsを使っているのでres.renderで値をejsへ渡してあげます。
// form postするためのページをレンダリング
res .render ("./form_post.ejs" ,
{
redirect_uri : req .body .redirect_uri ,
id_token : id_token ,
state : req .body .state
}
);
フォーム側はこんな感じです。実際は値はhiddenにして、JavaScriptで自動POSTするようにしますが、今回はステップバイステップで進めたかったので一旦フォームを表示するようにしています。
< html >
< body >
< div id ="login_div" >
< form name ="id_token" method ="POST" action =" < %= redirect_uri%>" >
< input name ="id_token" type ="text" value =" < %= id_token%>" >
< input name ="state" type ="text" value =" < %= state%>" >
< input type ="submit" value ="POST" >
</ form >
</ div >
</ body >
</ html >
これでうまくいけば上記の動画のように認証が完了します。
一旦、今回はここまでです。