おはようございます。ritouです。 前回の記事に引き続き、FedCM入門です。
何の話か
6/21(火) 19時から、OpenIDファウンデーション・ジャパン主催のイベントがあります。
明日じゃん。
これに先立って、Federated Credential Management API(FedCM) について 非公式 の入門記事を書いています。 今回は、FedCMの現状の実装について解説します。
- ID連携の課題とFedCMのアプローチ
- 現状のFedCM実装解説 <- 今回はこれ
- FedCM vs OIDC 仕様の差分、RP/IdPの対応、差分解消案
それでは始めましょう。
登場人物
FedCMの登場人物は、わりと一般的なID連携のものと同じです。
- IdP : Identity Provider. 他サービスに対してユーザー情報を提供する サービス
- RP : Relying Party. IdPのユーザー情報を用いて認証機能を実現する サービス
- ユーザー : IdP/RPそれぞれを利用するユーザー
- ブラウザ : FedCMに対応したブラウザ
ID連携フロー
FedCMはGOALとしてOpenID Connect(OIDC)/SAMLを対象にするとか言ってましたが、今のところは RP上にID連携のためのプロンプトを出してOIDCを簡略化したような仕組みでユーザー情報を受け渡してログインさせる というのが実装されています。 言い換えると、現在のFedCMで実装されている機能はGoogleが提供するOne Tap Sign-In and Sign-Up(Twitter, Medium, 他いろんなサービスでGoogleアカウントでログインするか?って出てくるやつ)相当である ということです。
はじめにFedCMを用いたID連携の流れをざっくり説明します。
- ユーザーはIdPにログインしている前提
- ユーザーがRPで "IdPでログイン" を利用しようとして、RPはFedCMのAPIを呼び出す
- ブラウザはIdPに対してログイン中のアカウント情報(リスト)を要求し、ID連携のためのプロンプトをRPドメイン上で表示する
- ブラウザはユーザーが選択したアカウント情報に紐づく認証用トークン(OIDCのIDToken)をIdPに要求し、取得したものをRPに渡す。RPはそれを認証機能に利用します。
まず前提として、1. でユーザーがIdPにログイン中である必要があります。一般的なWebアプリケーションのような単一ユーザーがログインできる仕組み、もしくはGoogleのような複数アカウントでログインできる仕組みにも対応しています。
プライバシー保護の観点から、RPが"IdPに誰がログインしているか、誰もログインしていないか" を知りえる状態は良くありません。現状、IdPに誰もログインしていない場合は、RPがFedCMのAPIを呼び出しても汎用的なエラーのみが返されます。この辺、FedCMのユースケースはまた別途考察しようと思っているところですが、実際は ログイン中のユーザーがいる状態のID連携の利便性を上げるショートカット 的な感じに使われることになるのかなーというところです。
2でFedCMのAPIを呼び出す部分はこの後説明します。
3で出てくるプロンプトですが、あるユーザーが単体でログイン状態の場合は次のような画像となります。(私のメアドは公開されてるようなものなのでよし)
「続行」をユーザーがクリックすると、User-Agent は IdP にリクエストが送られ、RPにユーザー情報+認証イベントの情報を含む IDToken (具体的な内容はIdP依存だけど実質OIDC互換)が渡されます。
Googleのように同時に複数アカウントがログインできる仕組みの場合、この画面の前にアカウントリストから選択するプロンプトが入ります。
4ではRPはそれを使ってよしなにログインするなり新規登録するなりをします。ここはRPの要件に依存します。
FedCMではID連携フロー以外にログアウトだったりIDToken無効化(退会?)のリクエストを送ることも可能ですが、現状としてはここをまずおさえておけばいいでしょう。
ID連携のシーケンス
ここまでざっくり説明したFedCMのID連携フローで、実際何が行われるかは公式ドキュメントで公開されています。
シーケンスは以下の通りです。
この図だけで大体わかっちゃう人がいたら是非一緒に働きましょう。
ここからは、公式のドキュメントや自分で作成したRP/IdPの動作例を用いて説明していきます。
0. ブラウザがFedCM対応環境環境かどうかを確認する
実際のID連携のプロダクトでは、FedCMが有効ではない環境のフォールバックも当然必要です。
- FedCMが対応環境ならば優先的に利用
- 非対応環境ではこれまで通りのID連携
みたいなところを自然なUXで提供する必要があります。
現状では、RPは以下のコードでFedCMのID連携フローが利用可能かどうかの判定ができます。
FedCMの名前にもなってる FederatedCredential
っていう仕組みは既にあるものなので、それとloginが実装されているかを組み合わせで判定します。
if (window.FederatedCredential || FederatedCredential.prototype.login) { // If the feature is available, take action }
ちょっと話はずれますが、個人的に現在の One Tap sign-in and sign-up は未ログイン状態の時にプロンプトを節操なく出してくる印象があるんですが、それと同様ではなくログインや新規登録のフローに入ったときに絞るなどの検討が必要かなという印象です。
1. RPがFedCMの関数を呼び出してID連携を要求
RPがFedCMのID連携を開始する処理は navigator.credentials.get
の呼び出しから始まります。
ここでRPはIdPのURLと自分自身の識別子(clientId
)を指定します。
const credential = await navigator.credentials.get({ federated: { providers: [{ url: 'https://ex-fedcm-idp.herokuapp.com/', clientId: 'https://ex-fedcm-rp.herokuapp.com/' }] } })
上記サンプル実装では、clientId
にRP自身のURLをそのまま実装していますが、これはIdPが提供するClient登録機能などで払い出しされた値を指定することになります。
ちなみに、providers
とあるように、ここでは複数のIdPのアカウントとの連携も視野に入れられているように見えますね。
現状の実装としては、最初の1個を読むようになってるような挙動をしていますが、ここで複数が指定されるようになったら一番最初に "プロバイダ選択" みたいなプロンプトになるのかなーという想像をしています。
FedCMではここから FederatedCredential.login()
を呼び出すことでRPはブラウザはID連携の処理を開始します。
const nonce = '65a7c572-b4fc-4e27-b899-c67e12ac36a5'; const { id_token } = await credential.login({ nonce });
ここでOIDCにおける nonce
パラメータを指定することで、ブラウザがIdPから取得してRPに渡されるIDTokenがこのセッション/リクエストに紐づいていることを検証可能です(いわゆるCSRF/リプレイアタック対策みたいな話)。
ブラウザがIdPに送る最初のリクエストは "Top level domain manifest" と記載されているリクエストです。
GET /.well-known/fedcm.json HTTP/1.1 Host: ex-fedcm-idp.herokuapp.com Accept: application/json Sec-FedCM-CSRF: ?1
$ curl "https://ex-fedcm-idp.herokuapp.com/.well-known/fedcm.json" -H "Sec-FedCM-CSRF:?1" -H "Accept:application/json" {"provider_urls":["https://ex-fedcm-idp.herokuapp.com/"]}
ここでは、RPが指定したIdPのURLがFedCMに対応しているか、リクエストを送っても良いかどうかを確認します。いわゆるマルチテナントなIdPなどでは provider_urls
の値が複数返されることになります。
その後に、ドキュメントで "IdP manifest file" と記載されているエンドポイントにリクエストが送られます。これは IdP で指定されたURLにある "/fedcm.json"
というエンドポイントです。
GET /fedcm.json HTTP/1.1 Host: ex-fedcm-idp.herokuapp.com Accept: application/json Sec-FedCM-CSRF: ?1
$ curl "https://ex-fedcm-idp.herokuapp.com/fedcm.json" -H "Sec-FedCM-CSRF:?1" -H "Accept:application/json" {"accounts_endpoint":"/accounts", "branding":{"background_color":"0xFF4500","color":"0xFFFFFF","icons":[{"size":32,"url":"https://ex-fedcm-idp.herokuapp.com/images/icon_32.ico"}]}, "client_metadata_endpoint":"/client_metadata", "id_token_endpoint":"/id_token"}
このレスポンスには各種エンドポイントとプロンプトを出す際のアイコンや色といった情報(branding
)が含まれます。
ブラウザはこの後、プロンプトに表示する Client 情報(利用規約、プライバシーポリシーのURL)を要求するために "client_metadata_endpoint"
にリクエストを送ります。
GET /client_metadata?client_id=https%3A%2F%2Fex-fedcm-rp.herokuapp.com%2F HTTP/1.1 Host: ex-fedcm-idp.herokuapp.com Referer: https://ex-fedcm-rp.herokuapp.com/ Accept: application/json Sec-FedCM-CSRF: ?1
$ curl "https://ex-fedcm-idp.herokuapp.com/client_metadata?client_id=https%3A%2F%2Fex-fedcm-rp.herokuapp.com%2F" -H "Sec-FedCM-CSRF:?1" -H "Referer:https://ex-fedcm-rp.herokuapp.com/" -H "Accept:application/json" {"privacy_policy_url":"https://ex-fedcm-rp.herokuapp.com/pp", "terms_of_service_url":"https://ex-fedcm-rp.herokuapp.com/tos"}
RP じゃなく IdP が RP の metadata を出す というところがやや引っかかりますが、まぁいいでしょう。ここで返されたURLはプロンプト内でリンクとして使われます。
また、このリクエストには当然 client_id
の値が含まれますが、この後の Accounts list endpoint へのリクエストには含まれません。
IdPがClientの検証を行うには、この段階で client_id
パラメータと Referer
ヘッダあたりを利用して検証する必要がありそうです。
次に、ブラウザは Accounts list endpoint に現在ログイン中のアカウントリストを要求します。
GET /accounts_list.php HTTP/1.1 Host: ex-fedcm-idp.herokuapp.com Accept: application/json Cookie: ...IdP's Cookie... Sec-FedCM-CSRF: ?1
$ curl "https://ex-fedcm-idp.herokuapp.com/accounts" -H "Sec-FedCM-CSRF:?1" -H "Referer:https://ex-fedcm-rp.herokuapp.com/" -H "Cookie:..." -H "Accept:application/json" {"accounts": [ {"approved_clients":[], "email":"ritou.06@gmail.com", "email_verified":true, "family_name":"Ito", "given_name":"Ryo", "id":"google_user_114181308725730985237", "name":"Ryo Ito", "picture":"https://lh3.googleusercontent.com/a-/AOh14GjQ_fcwsIRk6LalbnjCHWzWfk7BkYvX9XAkZP8b8Q=s96-c"} ] }
注目するべきは、ここで1st Party相当のCookieが送られます(6月頭の時点ではSameSite=None
(3rd Party相当)のものが送られるようになっているので Issueで連絡済みでしたが今確認したらStrictでもいけました!)
RPはFedCMのAPIを利用するだけで、IdPも1st Party相当のCookieのみで利便性の高いID連携を実現できるのがポイントですね。
レスポンスにはログイン中のユーザーリストが含まれ、ユーザー情報としてはユーザー識別子、メールアドレス、名前、プロフィール画像などが返されます。
2. ブラウザがユーザーにIdP/RPのアカウント情報、
概要で説明した通り、ここで複数のアカウント情報が返されたらブラウザはリスト表示、単一の場合は同意のプロンプトを表示します。
3. ブラウザがIdPにIDTokenを要求
ユーザーがブラウザのプロンプト上でID連携することに同意したら、ブラウザはIdPに対象ユーザーのIDTokenを要求します。このリクエストにもIdP向けのCookieが含まれます。
POST /id_token HTTP/1.1 Host: ex-fedcm-idp.herokuapp.com Referer: https://ex-fedcm-rp.herokuapp.com/ Content-Type: application/x-www-form-urlencoded Cookie: ...IdP's Cookie... Sec-FedCM-CSRF: ?1 account_id=google_user_114181308725730985237&client_id=https://ex-fedcm-rp.herokuapp.com/&disclosure_text_shown=true&nonce=1c64ca07-90f8-4eee-b3d4-d0eb871ea816
IdPは対象ユーザーのIDTokenをJSON形式で返します。
{ "id_token": "eyJ********" }
4. RPはIDTokenを受け取って認証処理などを行う
RPはブラウザから受け取ったIDTokenを利用して認証処理を行います。 呼び出したときのコードを振り返りましょう。
const nonce = '65a7c572-b4fc-4e27-b899-c67e12ac36a5'; const { idToken } = await credential.login({ nonce });
ドキュメントには { id_token }
とありますが現状のChrome Canaryの実装では { idToken }
で取得できます。これもそのうち治るでしょう。
この辺りは、RPにとっては、OIDCのImplicit Flowと呼ばれるものと同等の処理です。 細かい違いについては別記事で説明予定ですが、OIDCのIDTokenはJWT形式の文字列であり、次のステップを踏むことで検証できます。
- 署名が正しい
iss
パラメータがIdPのものであるaud
が自身のものであるexp
が有効期限内であるnonce
が "1. RPがFedCMの関数を呼び出してID連携を要求" で指定したものである
このような検証ステップを終えたら、RPは自サービス内のログインや新規登録処理を行います。
まとめ
今回はFedCMのID連携のフローで送られるリクエスト/レスポンスを説明しました。 ユーザーからの見た目はプロンプトが表示されてそこからID連携が行われるだけですが、裏側でいくつかの処理が行われています。
ここまでの流れはAndroid/PCの新しいChrome Canaryで動作確認できます。
興味があったら試していただいて、気になることがありましたらコメントしてください。
次回予告
現状、FedCMは OIDC寄り の仕様になっているものの、ブラウザ-IdP間のリクエスト/レスポンスはFedCM独自のものです。 特に既存のOIDC IdPの方がこれに対応した実装を行う必要がありますが、そのためにはOIDC/FedCMの仕様間のFit/Gapを整理する必要があります。 例えば、ブラウザのCookieを受け取りつつレスポンスの形式はJSONであるみたいな仕様はOIDCの仕様ではあまり馴染みがないものです。
そこで、次回は
- RPがID連携を要求する部分、OIDC準拠に近づくにはどうなるべき?
- OIDC IdPが楽にFedCMに対応するための考え方、違う部分についてはFedCMの仕様がこうなっていると捗る、もしくはOIDCにこのような拡張があると捗る
みたいなところを紹介します。
ではまた!