ラベル Entra ID の投稿を表示しています。 すべての投稿を表示
ラベル Entra ID の投稿を表示しています。 すべての投稿を表示

2024年10月7日月曜日

Entra IDを使ったパスワードレスでのオンボーディングシナリオ

こんにちは、富士榮です。

Entra IDもVerified IDやFIDOなど色々な要素が組み合わさってきているので、それらの機能をどうやって組み合わせて使うのが良いのか?という疑問が湧いてきます。

そんな時にパスワードレスでオンボーディングをするというシナリオに基づくデザイン〜実装ガイドがMicrosoftから発行されていますので、見てみようかと思います。

Phishing-resistant passwordless authentication deployment in Microsoft Entra ID

こちらのドキュメントです。

全体像はこんな感じですね。


Onboarding step 1: Identity verification

最初のステップではEntra Verified ID(+3rdパーティソリューション)を使って政府発行のIDなどで本人確認するところからスタートします。その後、PCのBootstrapではTAP(Temporary Access Pass)を使ってドメイン参加〜認証器のエンロールをする、という流れですね。(もしくは、最近PreviewになったGraph APIで事前にFIDO認証器をプロビジョニングしておく、という方法もありますね)

関連資料)
前のフェーズでTAPでBootstrapし、最初のクレデンシャルのエンロールをするタイミングです。ここで重要なのはデバイスにバインドされたクレデンシャルではなくポータブルなクレデンシャルをエンロールすべきである、という点です。当然働き方・デバイスの使い方によって事情は異なりますが、最初のクレデンシャルがデバイスにバインドされてしまうと後々困ることになるからですね。

Onboarding step 3: Bootstrap local credentials on computing devices

ポータブルなクレデンシャルがエンロールされれば、あとは個別のデバイスのセットアップを自由にできるわけです。この段階でデバイスごとのローカルクレデンシャルをエンロールしていきます。典型的にはWindows HelloのPINの生成ですね。要するにローカルの鍵ストアをオープンするための手段を作っていくところです。


まぁ、非常に典型的な話ではありますが、ドキュメントではもっと細かくパターン分けされたデザインが出てきますので、みなさんの仕事の仕方、デバイスの種類を考えて適切なデザインをしていってください。

2024年5月14日火曜日

Entra IDの外部認証プロバイダの設定を試す(3)

こんにちは、富士榮です。

引き続きEntra IDの外部認証について見ていきます。


今回は、前回のポストに記載したハマりポイントにも記載したjwksで公開する鍵の作り方を書いておきます。

ポイントはx5cを含む形でjwkを作成〜公開すること、です。要するに証明書を含め公開することでキーチェインがわかるようにする必要があるってことですね。

今回は当然自己証明証明書を使っているので、opensslで鍵ペアの作成等を行っています。

大まかには川崎さんが公開しているこちらのQiitaと類似の手順を通ればOKですが、今回はRSAでやったのでちょっと手順も異なる部分もあります。

前提)

  • MacOSを使っています
  • opensslはMacOS標準ではなくbrew install opensslをインストールした以下のバージョンを利用する必要があります(標準版だと一部新たなパラメータに対応していないため)
    • OpenSSL 3.3.0 9 Apr 2024 (Library: OpenSSL 3.3.0 9 Apr 2024)
  • pem-jwkを使いますのでnpm installしておいてください
  • jqを使いますので同じくnpm installしておいてください
ということで鍵生成をしていきます。

まずは鍵ペアの生成をします。いきなりjwk形式で作ります。

openssl genrsa -traditional 2048 | pem-jwk > keypair.jwk

次にpemに変換します。

pem-jwk keypair.jwk > keypair.pem

公開鍵を抽出します。

openssl pkey -pubout -in keypair.pem > public.pem

証明書を作ります。CNにはissuer名を指定します。 

openssl req -x509 -key keypair.pem -subj /CN=7...省略...25.ngrok-free.app -days 3650 > certificate.pem

jwkに証明書を入れます。

CERT=$(sed /-/d certificate.pem | tr -d \\n)

jq ".+{\"x5c\":[\"$CERT\"]}" keypair.jwk > key+cert.jwk


これで出来上がったjwkをjwks_uriに入れ込んで公開します。ちなみに上記ではuseのパラメータが設定されないことがあるので必要に応じて"sig"を手動でセットしておきます。

その後、こんな感じでjwksとしてセットします。


これをkeystoreとして読み込んでjwks_uriエンドポイントで公開します。

// jwks_uriエンドポイント
router.get('/jwks_uri', async (req, res) => {
const ks = fs.readFileSync(path.resolve(__dirname, "../keys/keystoreSign.json"));
const keyStore = await jose.JWK.asKeyStore(ks.toString());
res.json(keyStore.toJSON())
});

これでEntra IDが署名検証に使える状態で鍵を公開することができました。

こんな感じです。


まぁ、雑ですが一通りの原理はわかる状態まで持っていくことができました。

あとはちゃんと外部認証プロバイダ側を実装するだけですね。 

2024年5月13日月曜日

Entra IDの外部認証プロバイダの設定を試す(2)

こんにちは、富士榮です。

前回に引き続き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でログインする際にリクエストが来ているのがわかります。



外部認証プロバイダの認可エンドポイントへPOSTされてくるデータなどをtrafic inspectorで確認しながら実装を進めていくのが良いと思います。

ということで、外部認証プロバイダを実装していきます。

まずは認可エンドポイントです。

こちらが認可エンドポイントへ投げ込まれてくるパラメータですので、こちらに対応する形でid_tokenを発行して返してあげれば良いはずです。

パラメータ
scopeopenid
response_modeid_token
client_id外部認証プロバイダ側にEntra IDをRPとして登録した際のclient_idの値
redirect_urihttps://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"]}}}'
nonceEntra IDが払い出すnonceの値
id_token_hintEntra ID側で認証済みのユーザに関する情報
client-request-idEntra ID側でのトラッキングに使う識別子(サポート用)
stateEntra 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>

これでうまくいけば上記の動画のように認証が完了します。

一旦、今回はここまでです。

2024年4月3日水曜日

CopilotにEntra IDのことを聞いてみた

こんにちは、富士榮です。

今月からCopilot for Securityが正式ローンチということで盛り上がってますね。
Copilotはあまりウォッチしてこなかったので正直使い方を含めあまりわかっていませんが、取り急ぎセットアップはしてみました。(高いのですぐ消したけど)

ということでその記録です。

まずはこちらからセットアップを開始していきます。

管理権限のあるユーザでサインインするとセットアップが始まります。
必要な情報を入れていきます。
  • サブスクリプション
  • リソースグループ
  • 容量の名前
  • プロンプトの評価場所
  • 容量リージョン
  • ユニット数(1時間あたり4ドル/ユニット)
しかし高い!1ユニットだけでも月間2880ドル、1ドル=150円とすると432,000円です。まぁ、優秀な副操縦士(Copilot)がこの値段で雇えるならまあいいか、という話だとは思いますが個人課金のレベルではなさそうです。。

ちなみに、USリージョンを選んでセットアップをしていたのですが、一時的な問題なのかうまくセットアップが完了しませんでしたがUKを選んだらうまくいきました。

Copilotのリージョンとは関係なくデータはサブスクリプションの紐づいたリージョンに保存されるっぽいですね。

もう少しです。

設定が終わるとダッシュボードに遷移します。



まだあまり数はありませんがプロンプトライブラリも用意されています。


日本語で聞いてみると色々教えてくれます。

ちょっと難しい質問をしたらおかしな回答が返ってきたので英語でもう一度聞いてみましたが、やっぱりおかしな回答が返ってきました。
仲良くなるにはまだ時間がかかりそうです。
(課金が恐ろしいので消しちゃいましたが・・・)


ちなみに、別の方法でセットアップを行うこともできます。Azure PortalからCopilot for Securityのリソースを探して追加する方法です。先の方法はARMテンプレートを使ってセットアップしているだけなのでPortalでの方法とやっていることは変わりませんが。







2024年3月24日日曜日

Entra IDの条件付きアクセス+パスキー(特定のベンダのキーのみを許可する)

こんにちは、富士榮です。

先日、Entra IDの条件付きアクセスでパスキーの利用を強制する方法を書きました。

https://idmlab.eidentity.jp/2024/03/entra-id_20.html


今回はさらに一歩進めて、特定のベンダの認証器だけを使えるようにしてみたいと思います。

やるべきこととしては認証方法の登録時に認証器のAAGUID(Authenticator Attestation Global Unique Identifier)を合わせて登録することです。このことにより特定の認証器以外は受け付けないように設定を行うことができます。

ちなみにこのAAGUIDですが、えーじさんがBlogに書いていた通り、ボランティアベースでリストが作成されgithubで公開されています。

手動で確認する場合は、このレポジトリのREADMEに書かれているPasskeys Authenticator AAGUID Explorerを使うのが便利だと思います。このリストに記載されているものに加えてFIDOアライアンスのMeta Data Service(MDS)に登録されているものもあるので、両方合わせて表示するにはExplorerの左上にある「Include MDS Authenticators」をクリックすると世の中の認証器はほぼほぼ網羅できると思います。

例えば手元にあったeWBM eFA310 FIDO2 AuthenticatorのAAGUIDは95442b2e-f15e-4def-b270-efb106facb4eということがわかります。


なお、Entra IDに登録済みのセキュリティキーであればアカウントのセキュリティ情報のページからAAGUIDを確認することもできます。


ということで認証方法の絞り込みをしていきましょう。先日のポストの中で認証方法の設定でパスキーのみを設定する方法を記載しましたが、その中で詳細オプションがあることを書きました。この詳細オプションを開くとAAGUIDの許可リストを記載することができるので、許可したい認証器のAAGUIDを先ほどのExplorerやセキュリティ情報のページから取得して登録してあげればOkです。

これで完了です。

未登録のAAGUIDの認証器を使ってログインしようとすると条件付きアクセスポリシーが働き、認証には成功するもののアクセス制限がかかりました。

ということで、認証器の強度評価をして許可リストを作って運用するような環境においてはこの設定を使うことで例えばYubikeyのこのモデルはOKだけど、eWBMのキーはダメ、などの制御を行うことができるようになります。

環境統制レベルが低いパブリックに近い環境で組織内のアプリを使わせたいケースなどにおいてはこのような制御が有効なケースもあると思うので活用してみると良いと思います。

2024年3月20日水曜日

Entra IDの条件付きアクセスでパスキーを使った認証を強制する

こんにちは、富士榮です。

前回、Entra IDの条件付きアクセスがデバイスコードフローなどの認証フロー単位で適用できるようになった(Preview)という話をしました。

https://idmlab.eidentity.jp/2024/03/entra-id.html


すると、某FacebXXkで某氏よりTeams Roomとかで使えそう、かつWindows Helloを除外した上でYubikeyなどFIDOキーを使った認証を強制できると良さそう、、というコメントをもらったのでやってみました。



やることは、

  • 認証強度の設定を行う
  • 条件付きアクセスに設定した認証強度を紐づける

です。


認証強度の設定

認証強度、というとよくわかりませんが、認証方法をカスタムで設定できるEntra IDのセキュリティ機能です。

認証方法→認証強度のメニューで「新しい認証強度」の追加を行います。この際に、パスキー(FIDO2)を選択します。Windows Hello for Businessや証明書ベース認証などと分けて設定ができるので、Windows Helloなどとは分けて設定ができます。ちなみにここでパスキー(FIDO2)として設定を行うとtransportがusb/nfcに設定されるっぽいのでplatform authenticatorは除外できます。細かいパラメータは先日のポストで書きましたので知りたい方はこちらへどうぞ。

また、詳細オプションを設定することでAuthenticator Attestation GUID(AAGUID)を設定することもできるので認証器の種類まで絞り込むこともできます。


条件付きアクセスの設定を行う

前回のポストで紹介したデバイスコードフローをブロックするポリシーをベースにカスタマイズしてみます。

やりたいことは、

  • デバイスコードフローの場合
  • パスキー(FIDO2)での認証を強制する
です。

条件付きアクセスの設定の許可のところでブロックしていたところを「アクセス権の許可」を選択、「認証強度が必要」から先ほど作成した認証強度を選択します。


設定としてはこれでおしまいです。

しばらく設定が馴染むまで待ちましょう。


動作確認

早速動きを見てみましょう。

ざっくりいうと、パスワードで認証するとパスキーでの追加認証が求められ、最初からパスキーで認証するとそのままログインが完了します。

以下の例ではまずはパスワードで認証しています。

するとパスキーでの追加認証が求められます。
認証器を使って認証します。

ログインが完了します。

確かに某氏が言うように割と不特定多数が触れる環境(例えばゲスト用の会議室など)でデバイスコードフローを使う場合にPINのみでログインされてしまう可能性などを鑑みるとこう言う使い方もありかもしれませんね。



2024年3月19日火曜日

Entra IDの新しい条件付きアクセスを試す(デバイスコードフロー編)

こんにちは、富士榮です。

そういえばEntra IDの条件付きアクセスに新しい条件が追加されましたね。
今回追加されたのは認証フローという条件で、
  • デバイスコードフロー
  • 認証の転送
の2つの条件をサポートしています。


関連するドキュメントはこちらにあります。

デバイスコードフローはともかく「認証の転送」とは??という疑問はもっともですが、簡単にいうとPCブラウザ等でログインを求められる際にQRコードなどでモバイルデバイスを呼び出してモバイルデバイス側で認証する、という構成を想定した条件です。(Outlookとかであるはず)

当該する条件に合致するときにブロックしたり多要素認証を要求したりすることができるのがこの条件付きアクセスという機能なので、きっとそのようなシナリオがどこかであったんだと思います。(ちなみにこの機能を使うにはAzure Active Directory Premium P1が必要です)

ということでポリシーを作ってみます。
今回はデバイスコードフローをブロックするシナリオで試してみましょう。
条件としては、
  • 全てのユーザが
  • デバイスコードフロー用のテストアプリにアクセスしようとした場合
  • 認証フローが「デバイスコードフロー」だったら
  • アクセスをブロックする
というポリシーを作ってみました。
(上記のスクリーンショットのもの)

ということでアクセスしてみます。
デバイスコードフローなので
https://login.microsoftonline.com/{テナントID}/oauth2/v2.0/devicecode
に対してclient_idとscopeをx-www-form-urlencodedでPOSTしてあげるだけですね。

こんな感じでPostmanでリクエストをしてみます。

あとはレスポンスの中にあるverification_uriにアクセスしてuser_codeを入れてあげれば認証完了です。(通常はこのレスポンスをQRコードにして表示してスマホで読み込んだりさせます)
今回はそのままverification_uriを開いてみます。

そしてuser_codeを入れるとログインが求められます。

今回はデバイスコードフローをブロックするのでポリシーが正常に動いていればアクセスがブロックされます。



条件付きアクセスは色々と新しい条件がついていてかなりきめ細かいアクセス制御ができるようになってきています。
アプリケーションの利用シーン(環境など)をベースにいろいろなポリシーを構成してみてください。





2024年1月5日金曜日

バックエンドが充実していない環境でOpenID Providerを作る

こんにちは、富士榮です。


昨日こんなことを書きましたので、ちょっと深掘りしてみようと思います。

この辺りは別ポストで詳しくお話しようと思いますが、最後までステートレスにこだわったMicrosoftは認可コードをJWTにして認証済みユーザの属性情報を暗号化して入れることでトークン要求時に認可コードからセッションをLookupしてid_tokenを生成する必要をなくしていたり、VittorioがAuthorのRFC9068/JWT Profile for OAuth 2.0 Access Tokenの様にIntrospectionエンドポイントが無くてもトークンの有効性検証ができる仕組みを実装したり、Hybrid Flowでもないのにフロントで取得できるid_tokenにはほとんど情報を入れず、userInfoやGraph APIへのアクセスをしないとユーザ属性が全く取れなかったり、、、とグローバルスケールの認証基盤を運営する上での苦労が滲み出ていたりします


要するにEntra IDの話なんですが、良いか悪いかは置いておいてOpenID Providerを作るときにバックエンドにDBをなるべく持たずに認可コード、アクセストークン、IDトークンのやりとりを一貫性を持って実行するための工夫の話です。


そもそも何が課題なのか?

ご存知の通りOpenID ConnectやOAuthはIDトークンやアクセストークンを取得するためにユーザエージェント(ブラウザ)とRelying Party(クライアント)が認可エンドポイントとトークンエンドポイントとの間のアクセスを行ったり来たりする必要があります。(いわゆるOAuth Dance)

これを実装しようとすると認可エンドポイントへのアクセスとトークンエンドポイントへのアクセスが本当に同一トランザクションの中でのやりとりなのかを確認しないといけませんし、サーバ(OpenID Provider)側としてはトランザクション内での情報の保持をしないといけなくなります。ただHTTPは本来ステートレスな仕組みなので、通常のWebアプリケーションを作る場合と同じ様にサーバ側でのセッション管理をどうするか、しかも認可エンドポイントはユーザエージェントから直接アクセス、トークンエンドポイントはクライアントからのバックエンドアクセスという形なのでcookieで、というわけにも行きません。

通常の実装では何の疑問もなくバックエンドにデータベースを置いてステート管理をサーバ側で実装することになるのですが、グローバルでスケールしている認証サービスの様に極めて高い可用性が必要とされるサービスでその様なインフラを作るのは非常にコストが高い行為といえます。(Entra IDの様に基本無償で提供される様なサービスなら難しい判断になると思います)


そうだ、クライアントにステート情報のハンドリングを任せよう!

そこで考えられたのが全てのやりとりの中で最終的にIDトークンやアクセストークンを発行するのに必要となる情報をもと回れば良いではないか、という考え方だと思われます。(Entra ID以外で見たことはありませんが)

簡単に流れを説明するとこんな感じになっているんだと思います。

認可エンドポイント

  1. ユーザを認証する→最終的にIDトークンに含めるユーザの属性情報を取得する
  2. nonceやaud(クライアントID)など、同じく最終的にIDトークンに含める属性をクエリパラメータから取得する
  3. 1,2で取得した情報を暗号化してJWTにして認可コードとしてクライアントへ戻す
トークンエンドポイント
  1. クライアントからPOSTされた認可コードの検証、復号を行う
  2. 復号した情報をもとにIDトークンを生成してクライアントへ返却する


この辺りをミニマムで実装してみるとこんな感じのコードになると思います。(テスト実装なので細かいところは気にしないでください)



const router = require("express").Router();
const jwt = require("jsonwebtoken");

// jwt署名に使う共有シークレット
const jwtSecretForCode = "this_is_a_secret_for_code_signing";
const jwtSecretForToken = "this_is_a_secret_for_token_signing";

// 認可エンドポイント
router.get("/authorization", async (req, res) => {
    // 本来なら実装する処理
    // - ユーザの認証
    // - response_typeによるフローの振り分け
    // - client_idの登録状態の確認
    // - redirect_uriとclient_idが示すクライアントとの対応確認
    // - scopeの確認
    // - 属性送出に関する同意画面の表示

    // codeの生成(本来は暗号化しておく)
    // 最終的にid_tokenに入れる値をDBに保存する代わりに暗号化してcodeに入れておくことでバックエンドを持たずにすませる
    const jwtPayload = {
        iss: "myOpenIDProvider",
        aud: req.query.client_id,
        sub: "authenticatedUserIdentifier",
        email: "test@example.jp",
        given_name: "taro",
        family_name: "test",
        nonce: req.query.nonce
    };
    const jwtOptions = {
        algorithm: "HS256", // 面倒なのでHS256にする
        expiresIn: "30s" // コードの有効期限なので短め
    };
    const code = jwt.sign(jwtPayload, jwtSecretForCode, jwtOptions);
    // redirect_uriへリダイレクト
    res.redirect(req.query.redirect_uri + "?code=" + code + "&state=" + req.query.state);
});

// トークンエンドポイント
router.post("/token", (req, res) => {
    // 本来なら実装する処理
    // - クライアントの認証
    // - grant_typeの検証
    // - codeの検証(有効期限、発行先クライアント、スコープ)
    // - access_tokenの発行
    // - id_tokenの発行
    const code = req.body.code;
    jwt.verify(code, jwtSecretForCode, (err, decoded)=> {
        if(err){
            res.json({
                err: "counld not verify code"
            })
        };
        if(decoded){
            // 本来は検証が終わったらcodeを無効化する
            // codeの中身を取り出してid_tokenを作る
            const jwtPayload = {
                iss: decoded.iss,
                aud: decoded.aud,
                sub: decoded.sub,
                email: decoded.email,
                given_name: decoded.given_name,
                family_name: decoded.family_name,
                nonce: decoded.nonce
            };
            const jwtOptions = {
                algorithm: "HS256", // 面倒なのでHS256
                expiresIn: "10m" // id_tokenの有効期限なので少しだけ長め
            };
            const token = jwt.sign(jwtPayload, jwtSecretForToken, jwtOptions);
            res.json({
                access_token: token, // ちなみにEntra IDの場合はaccess_tokenもid_tokenとほぼ同じものが使われるケースもある。
                token_type: "Bearer",
                expires_in: 3600,
                id_token: token
            });
        }
    });
});

module.exports = router;


実際に動きを見てみます。
  • 認可エンドポイントへのアクセス
http://localhost:3000/oauth2/authorization?client_id=111&redirect_uri=https://localhost:3000/cb&state=hoge

  • コールバックへのリダイレクト
https://localhost:3000/cb?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteU9wZW5JRFByb3ZpZGVyIiwiYXVkIjoiMTExIiwic3ViIjoiYXV0aGVudGljYXRlZFVzZXJJZGVudGlmaWVyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuanAiLCJnaXZlbl9uYW1lIjoidGFybyIsImZhbWlseV9uYW1lIjoidGVzdCIsImlhdCI6MTcwNDQyMDk1MCwiZXhwIjoxNzA0NDIwOTgwfQ.XKi6JLc5IwDNyZG1C7Lrk1UrBiWpOV5EC2NgpqWSXjk&state=hoge

  • 認可コードの中身(面倒なので今回は暗号化はしていません)
  • トークンエンドポイントへのPOST 

  • 取得できたIDトークンの中身



本当にこれでいいのか?

まぁ確かにスケーラビリティを考えると非常にエコなので実装としてはありだと思いますが、色々と割り切りをしないといけません。

例えば、

  • 認可コードのサイズが大きくなる
  • アクセストークンはJWT形式(内包型トークン)が前提となり、Introspectionエンドポイントの実装はできない
などが代表的なところです。
特に認可コードのサイズが大きくなる問題は結構深刻で、認可エンドポイントからクライアントへのリダイレクト時のクエリパラメータの長さがものすごいことになりますので、クライアントの実装によっては認可コードが受け取れない、ということが結構あります。ここはMSALを使え!というMicrosoftの理屈なんだと思いますが、既存のアプリケーションとEntra IDを繋ぐ際にはしばしば問題になる可能性があるので、Entra IDの導入を行う際は要注意のポイントの一つです。

まぁ、いずれにしてもID基盤の導入はアプリケーション開発側との認識合わせ・仕様合わせが一番大きなポーションを占めると思うので、この辺りも念頭におきながらEntra IDライフを楽しむべきでしょう。

2024年1月4日木曜日

IDトークン発行のタイミングで認証済みユーザの情報をどこまで保存するか

こんにちは、富士榮です。

OpenID Providerを自前で実装する際の悩みポイントの一つが認可コード、アクセストークンをサーバ側でどの様に保存するか、という問題です。

どういうことかというと、認可コードを発行するタイミングで通常はユーザの認証を行うわけですが、そのタイミングで認証済みユーザの属性情報を取得しておいて認可コード、アクセストークンと紐づけてテーブルに保存をしておく方が実装は簡単になる一方で、最新のユーザ情報をuserInfoから取得したい場合の対応が結局必要になったり、ユーザの削除の検知のメカニズムの実装が必要になる、という話です。

ちなみに、認可コードやアクセストークンをサーバ側で一定期間保持をし続けるための実装がOpenID Providerの可用性やスケーラビリティを左右する最大のポイントの一つだと思うので、この辺りは各サービス事業者の考え方で分かれるところです。(この辺りは別ポストで詳しくお話しようと思いますが、最後までステートレスにこだわったMicrosoftは認可コードをJWTにして認証済みユーザの属性情報を暗号化して入れることでトークン要求時に認可コードからセッションをLookupしてid_tokenを生成する必要をなくしていたり、VittorioがAuthorのRFC9068/JWT Profile for OAuth 2.0 Access Tokenの様にIntrospectionエンドポイントが無くてもトークンの有効性検証ができる仕組みを実装したり、Hybrid Flowでもないのにフロントで取得できるid_tokenにはほとんど情報を入れず、userInfoやGraph APIへのアクセスをしないとユーザ属性が全く取れなかったり、、、とグローバルスケールの認証基盤を運営する上での苦労が滲み出ていたりします)

横道にそれましたが、一番簡単な実装だと認可エンドポイントへアクセスし認証されたタイミングで以下のレコードを保存しておき、


トークンエンドポイントへアクセスしアクセストークンやIDトークンを発行する際は必要なカラムを追加するなどを含めこのテーブルだけで処理がクローズできるので実装はシンプル、という話です。こんな感じです。



しかし、この実装のメリットはこのレコードだけで全ての処理が完結するため処理がシンプルになること、そしてid_token発行時とuserInfoアクセス時の間に仮にユーザの属性の更新をされても認証時点のユーザの属性情報をクライアントへ返却できる、という点があります。一方でユーザの最新の属性をuserInfoエンドポイントから返却したいケースやユーザの削除されたことを検知するメカニズムの実装が結局必要になる、など考慮点も存在します。

そのため、実際にはユーザの識別子だけをレコード状には記録しておき、トークンエンドポイントやuserInfoエンドポイントへのアクセス時は対応するユーザの属性をデータベースから取得する、という実装になるはずです。


ということで実際どういう実装になっていそうなのか確認してみます。

Microsoft Entra ID

いわゆるAzure Active Directoryです。
そもそもIDトークンに識別子以外の属性情報が入らないので自然と上記の実装になっていると考えて良いと思います。


LINE Login

こちらはid_tokenにも認証されたユーザの属性情報が入ります。

userInfoにアクセスする前にLINEアプリで名前を「開発用」から変更してみました。

この状態でuserInfoにアクセスすると、、

はい、更新後の値が取得されます。


まぁ、普通に考えたらこういう実装になるだろうな、という話でした。

2024年1月3日水曜日

ログインさせたいユーザを指定してIdPへFederationする(SAML/OpenID Connect)

こんにちは、富士榮です。

SAMLでもOpenID ConnectでもFederation構成をしているとService Provider(SP)やRelying Party(RP)側であらかじめIdentity Provider(IdP)側で認証させたいユーザ名を指定したいケースがしばしばあります。
典型的なケースの一つは多要素認証をオンデマンドで実行させた場合で、普通のページには第1要素であるユーザ名とパスワードで認証、決済ページには追加要素で認証を要求する、というシナリオは結構あり得るシナリオです。この場合、第1要素で認証されたユーザと追加要素で認証されたユーザをSP/RP側で比較することで同じ人であることを検証することももちろん可能なのですが、できればあらかじめ追加要素で認証させたいユーザの識別子をSP/RPからIdPに渡してあげることで利用者が再度ユーザIDをIdP側で入力する必要がなくなるのでUXも向上します。
この様なシナリオは当然のことながらSAMLでもOpenID Connectでもサポートされています。

SAMLの場合

SAMLを使う場合、SPからのSAML Requestの中にSubjectを指定することができます。
OASISのSAML2.0 coreの仕様の3.4.1のAuthnRequestのエレメントの章に記載があるSubjectが該当します。
SAML 2.0 core仕様

https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf

 こんな感じのリクエストになります。

<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ForceAuthn="false"
                    ID="a133c62aafc8dcee7a69481de5af763c4ee370494"
                    IssueInstant="2024-01-03T03:28:40Z"
                    Destination="https://idp.example.jp/sso/login"
                    AssertionConsumerServiceURL="https://sp.example.com/acs"
                    ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
                    Version="2.0"
                    >
    <saml:Issuer>https://sp.example.com/sp</saml:Issuer>
    <saml:Subject>
        <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">test@example.jp</saml:NameID>
    </saml:Subject>
</samlp:AuthnRequest>
実際の動作は上記リクエストを受け取ったIdP側の実装に依存するので利用するIdPの仕様を確認してださい。ちなみにEntra ID(Azure AD)の場合はこの方法ではなくクエリパラメータにlogin_hint=test@example.jpという形で付加してリクエストを投げる形になります。(この辺りを参照:https://learn.microsoft.com/ja-jp/entra/identity-platform/single-sign-on-saml-protocol#subject)ただし、Entra IDが外部IdPとFederationしている時はlogin_hintではなくusername=test@example.jpという形でパラメータ名が異なるので要注意です。
また、Azure AD B2Cでは要求リゾルバという仕組みでSubjectの情報を取得することができるので、このサブジェクトに応じた処理を書けば割と自由に実装ができます。
Okta CIC(旧Auth0)はSAML RequestのSubjectをちゃんと判別してくれそうです。(この辺りを参照:https://community.auth0.com/t/pass-login-hint-to-saml-provider/92546

OpenID Connectの場合

こちらはシンプルにlogin_hint属性をクエリパラメータに付加することで実現できます。(仕様はこちらを参照;https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html)こんな感じでAuthentication Requestにパラメータをつけるだけです。
HTTP/1.1 302 Found
  Location: https://server.example.com/authorize?
    response_type=code
    &scope=openid%20profile%20email
    &client_id=s6BhdRkqt3
    &state=af0ifjsldkj
    &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
    &login_hint=test@example.jp
こちらもSAMLと同様にIdPの実装に依存するので実際の動作はIdPの仕様を確認してみてください。ちなみにAzure AD B2Cの場合はSAMLと同じく要求リゾルバで属性の取得ができます。

ちなみに他にもヒント系のパラメータは色々とあります。
また、SP/RPから認証コンテキストを指定したい場合もあるのですが、現在SAMLについてはAuthnContextClassRefというパラメータがありますが、OpenID Connectについてはacr/amrをclaimsパラメータで渡す方法はありますが、レベルの指定方法の共通化をどうするべきか?についてはPam Dingleさんを中心にIntenet Identity Workshopで議論が続けられているので今後の注目ポイントかもしれません。