Phishing-resistant passwordless authentication deployment in Microsoft Entra ID
Onboarding step 1: Identity verification
- Entra Verified ID
- Temporary Access Pass
- Graph APIでもFIDOクレデンシャルのプロビジョニング
いろんなアイデンティティ管理系製品やサービスの実験の記録をしていきます。 後は、関連するニュースなどを徒然と。
こんにちは、富士榮です。
引き続きEntra IDの外部認証について見ていきます。
今回は、前回のポストに記載したハマりポイントにも記載したjwksで公開する鍵の作り方を書いておきます。
ポイントはx5cを含む形でjwkを作成〜公開すること、です。要するに証明書を含め公開することでキーチェインがわかるようにする必要があるってことですね。
今回は当然自己証明証明書を使っているので、opensslで鍵ペアの作成等を行っています。
大まかには川崎さんが公開しているこちらのQiitaと類似の手順を通ればOKですが、今回はRSAでやったのでちょっと手順も異なる部分もあります。
前提)
まずは鍵ペアの生成をします。いきなり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エンドポイントで公開します。
これでEntra IDが署名検証に使える状態で鍵を公開することができました。
こんな感じです。
まぁ、雑ですが一通りの原理はわかる状態まで持っていくことができました。
あとはちゃんと外部認証プロバイダ側を実装するだけですね。
こんにちは、富士榮です。
前回に引き続きEntra IDの外部認証を試していきます。
こちらのドキュメントを見つつ設定をしていきますが、なかなかハマりポイントがあります。ちなみに外部認証は細かい動きを見るためにも自前のIdPを使っていきます。
ハマりポイントだけ先に書いておきます。
とりあえず、こんな感じで動きます。ngrokでローカルで動かしているIdPを読みにこさせているので画面左側にtrafic inspectorを出しています。Entra IDでログインする際にリクエストが来ているのがわかります。
外部認証プロバイダの認可エンドポイントへ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してあげることとなります。
こんな感じでエンドポイントを作っていきましょう。
結構やることはありますが、今回はまずはid_tokenを発行するところにフォーカスを当てますので、ユーザの認証や各種パラメータの検証は省略します。
id_tokenに含めるべき値にリクエスト内のid_token_hintに含まれる情報があるので、まずばid_token_hintのpayloadをデコードして値を取り出せる状態にパースします。
そしてid_tokenのpayloadを生成します。今回は検証もユーザ認証もしないので、acrやamrの値も決め打ちで設定しています。ちなみにハマりポイントにも記載した通り、amrは配列で値を設定する必要がありますが、値は単一でなければなりません。(今回はfidoを設定)
そして、このpayloadを署名してJWTと作ります。
こちらも面倒だったので、opensslで作った秘密鍵をベタ打ちでコードに埋め込んでいますし、kidも生成したものをベタで指定しています。この辺はおいおい直します。
あとはid_tokenとstateをPOSTしてあげるだけですが、node+express+ejsを使っているのでres.renderで値をejsへ渡してあげます。
フォーム側はこんな感じです。実際は値はhiddenにして、JavaScriptで自動POSTするようにしますが、今回はステップバイステップで進めたかったので一旦フォームを表示するようにしています。
これでうまくいけば上記の動画のように認証が完了します。
一旦、今回はここまでです。
こんにちは、富士榮です。
先日、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の認証器を使ってログインしようとすると条件付きアクセスポリシーが働き、認証には成功するもののアクセス制限がかかりました。
ということで、認証器の強度評価をして許可リストを作って運用するような環境においてはこの設定を使うことで例えばYubikeyのこのモデルはOKだけど、eWBMのキーはダメ、などの制御を行うことができるようになります。環境統制レベルが低いパブリックに近い環境で組織内のアプリを使わせたいケースなどにおいてはこのような制御が有効なケースもあると思うので活用してみると良いと思います。
こんにちは、富士榮です。
前回、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)を設定することもできるので認証器の種類まで絞り込むこともできます。
前回のポストで紹介したデバイスコードフローをブロックするポリシーをベースにカスタマイズしてみます。
やりたいことは、
しばらく設定が馴染むまで待ちましょう。
ざっくりいうと、パスワードで認証するとパスキーでの追加認証が求められ、最初からパスキーで認証するとそのままログインが完了します。
以下の例ではまずはパスワードで認証しています。
確かに某氏が言うように割と不特定多数が触れる環境(例えばゲスト用の会議室など)でデバイスコードフローを使う場合にPINのみでログインされてしまう可能性などを鑑みるとこう言う使い方もありかもしれませんね。
こんにちは、富士榮です。
昨日こんなことを書きましたので、ちょっと深掘りしてみようと思います。
この辺りは別ポストで詳しくお話しようと思いますが、最後までステートレスにこだわった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 Provider)側としてはトランザクション内での情報の保持をしないといけなくなります。ただHTTPは本来ステートレスな仕組みなので、通常のWebアプリケーションを作る場合と同じ様にサーバ側でのセッション管理をどうするか、しかも認可エンドポイントはユーザエージェントから直接アクセス、トークンエンドポイントはクライアントからのバックエンドアクセスという形なのでcookieで、というわけにも行きません。
通常の実装では何の疑問もなくバックエンドにデータベースを置いてステート管理をサーバ側で実装することになるのですが、グローバルでスケールしている認証サービスの様に極めて高い可用性が必要とされるサービスでその様なインフラを作るのは非常にコストが高い行為といえます。(Entra IDの様に基本無償で提供される様なサービスなら難しい判断になると思います)
そこで考えられたのが全てのやりとりの中で最終的にIDトークンやアクセストークンを発行するのに必要となる情報をもと回れば良いではないか、という考え方だと思われます。(Entra 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
まぁ確かにスケーラビリティを考えると非常にエコなので実装としてはありだと思いますが、色々と割り切りをしないといけません。
例えば、
こんにちは、富士榮です。
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_token発行時とuserInfoアクセス時の間に仮にユーザの属性の更新をされても認証時点のユーザの属性情報をクライアントへ返却できる、という点があります。一方でユーザの最新の属性をuserInfoエンドポイントから返却したいケースやユーザの削除されたことを検知するメカニズムの実装が結局必要になる、など考慮点も存在します。
そのため、実際にはユーザの識別子だけをレコード状には記録しておき、トークンエンドポイントやuserInfoエンドポイントへのアクセス時は対応するユーザの属性をデータベースから取得する、という実装になるはずです。
ということで実際どういう実装になっていそうなのか確認してみます。
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>
HTTP/1.1 302 FoundLocation: 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