👫

FedCM が Chrome 108 から 使えるようになった

2022/12/27に公開

この記事は Digital Identity技術勉強会 #iddance Advent Calendar 2022 の20日目の記事です。

Chrome 108 から FedCM API が使えるようになったので、早速試してみました。

FedCM とはなにか

FedCM は、Federated Credential Management API の略で、3rd Party Cookie を廃止しても ID連携の仕組みが動くようにする仕組みです。

以下の挙動はよく目にするかと思います。これは 3rd Party Cookie によって IdP 側のログイン状態を確認できることによって実現されています。FedCM を使えば、3rd Party Cookie が廃止されても画像のようなシームレスなログイン体験を提供することができます。

登場人物紹介

  • Relying Party (RP): IDP を利用してログインするサービスのこと
  • Identity Provider (IDP): ユーザーの情報や、RP に対して権限移譲のを管理します。FedCM を利用した場合、IDP は、PR に対して Token を発行します
  • User Agent: ここでは、ブラウザ

RP が ブラウザに実装されている FedCM API を利用して、IDP が発行する Token を取得してユーザー認証を行うといった流れになります。

久しぶりに仕様を確認してみると、id token から token という名前に変わっていました。
以降は、仕様に合わせて token と呼びます.
-> https://github.com/fedidcg/FedCM/pull/257

id token という縛りが無くなった分、 フロントチャネルに渡るのが許容できなければ、 token を暗号化したりも選択肢とししてでてくるので、良かったのかなと感じます。

流れ

RP が、navigator.credentials.get() を呼び出すと ブラウザが IDP に設定ファイルなり、エンドポイントなりにリクエストを投げて最終的に、ブラウザにトークンを返します。RP はそれを受け取って、自身のバックエンドにトークンを使ってログインをするという流れになります。

それでは実際に実装してみます。サンプルコードでは以下のように動作します。

実装

サンプルコード
https://github.com/knwoop/fedcm-example

動作環境

  • Chome 108
  • Node.js: 16.14.0
  • Next.js: 13.0.7
  • React: 18.2.0
  • Go 1.19

IDP の実装

まず IDP の実装をまとめてみます。

  1. well-knwon ファイルを作成する
  2. IDP 設定ファイルエンドポイントの実装
  3. 各エンドポイントの実装
    1. Account List エンドポイント: IDP にログイン済みアカウント一覧を取得するエンドポイント。ブラウザが Cookie を送信することで IDP 側は、ユーザーを特定することが可能です
    2. Client Metadata エンドポイント: RP のメタデータ情報を取得するエンドポイント
    3. Identity Assertions: RP がログインするために必要なトークンを取得するためのエンドポイント

Step 1. well-knwon ファイルを作成する

まず、IDP の設定ファイルの URL を配列で持つ well-known ファイルを作成します。そしてそのファイル を IdP の eTLD+1 の /.well-known/web-identity から well-known ファイルを提供する必要があります。

たとえば、IDP が https://accounts.idp.example/ で 配信されている場合は、https://idp.example/.well-known/web-identity にwell-known ファイルを設定する必要があります。

後述する RP 側が navigator.credentials.get() を呼び出す箇所がありがますが、そこで IDP の設定ファイルを指定して一致している必要があります。

ファイルの形式

parameter required note
ProviderURLs IDP の設定ファイルの URLs

ここが複数してできることで、一つの domain でホストされている IDPがテナント構成になっているなどで Discovery エンドポイントが path ごとに複数管理されている場合でも対応できそうです。ちょっと管理が大変ですが。。。

サンプルコードでは struct から json を返していますが、実際には静的ファイルでいいと思います。(json 書くのがめんどく...)

type WebIdentity struct {
	ProviderURLs []string `json:"provider_urls"`
}

func (s *Server) func GetWellKnownFileHandler(w http.ResponseWriter, r *http.Request) {

	// FedCM API からなのかチェック
	if !fedcm.AllowedHeader(r) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	wi := &fedcm.WebIdentity{
		ProviderURLs: []string{
			"http://localhost:8080/config.json",
		},
	}

	w.Header().Set("content-type", "application/json")

	if err := json.NewEncoder(w).Encode(&wi); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
}

上記のコードに fedcm.AllowedHeader() 関数があるかとおもいます。これは、Sec-Fetch-Dest header に webidentity があるのかをチェックしています。これをすることで、FedCM ブラウザが送信したものであると確信することができます。XSS 攻撃から保護することができます。
しかし、これは今後、ブラウザによったら、対応しない可能性もあるので注意が必要です。

The FedCM API introduces several non-static endpoints on the IDP, so these need to be protected from XSS attacks. In order to do so, the FedCM API introduces a new value for the Sec-Fetch-Dest header, a forbidden header name. The requests initiated by the FedCM API have a webidentity value for this header. The value cannot be set by random websites, so the IDP can be confident that the request was originated by the FedCM browser rather than sent by a websites trying to run an XSS attack. An IDP must to check for this header’s value in the credentialed requests it receives, which ensures that the request was initiated by the user agent, based on the FedCM API. A malicious actor cannot spam FedCM API calls, so this is sufficient protection for the new IDP endpoints.
https://fedidcg.github.io/FedCM/#sec-fetch-dest-header

Step 2. IDP 設定ファイルの実装

IDP のこちらのエンドポイントは well-known エンドポイントにリストアップされるエンドポイントになります。このエンドポイントは、ブラウザが必要な IDP のエンドポイントのリストがあります。

それと、ボタンの色や icon なども設定することができます。
ブラウザリクエスト

GET /config.json HTTP/1.1
Host: idp.example
Accept: application/json
Sec-Fetch-Dest: webidentity

IDP は、以下の形式でレスポンスを返します。

レスポンスパラメーター

parameter required note
accounts_endpoint アカウントリスト URL
client_metadata_endpoint - クライアントメタデータ URL
id_assertion_endpoint IDP の設定ファイルの URL
branding - ui などのカスタマイズ設定
branding.background_color - ボタンの色を設定する
branding.color - ボタンテキストの色を設定する
icons - Icon の設定情報を配列

Icon

parameter required note
url icon の url, svg画像はサポートしていない
size - アイコンの大きさ. 25 以上必要

サンプル

{
  "accounts_endpoint": "/fedcm/fedcm/accounts_endpoint",
  "client_metadata_endpoint": "/fedcm/client_metadata_endpoint",
  "id_assertion_endpoint": "/fedcm/id_assertion_endpoint",
  "branding": {
    "background_color": "green",
    "color": "0xFFEEAA",
    "icons": [{
      "url": "https://idp.example/icon.ico",
      "size": 25
    }]
  }
}

Step 3. Account List エンドポイント

IDP にログインしているユーザーのリストを取得します。こちらなぜリストなのかというと google アカウントのような 1人が複数のアカウントを保持することがあるのでリストになっています。

ブラウザは、cookie を使ってリクエストを送信するので、IDP は cookie からセッションに紐づく user を取得します。

サンプルコードは登録しているユーザーを全て表示していますので悪しからず、、、

GET /accounts_endpoint HTTP/1.1
Host: accounts.idp.example
Accept: application/json
Cookie: 0x23223
Sec-Fetch-Dest: webidentity

IDP は、以下の形式でレスポンスを返します。

レスポンスパラメーター

parameter required note
id ユーザー識別子
name 名前
email メールアドレス
given_name - ユーザー名
picture - ボタンの色を設定する
branding.color - ボタンテキストの色を設定する
approved_clients - Icon の設定情報を配列

Step 4. Client Metadata エンドポイント

RP のプライバシーポリシーや利用規約のリンク先を返します。

ブラウザは Cookie を送信せず、client_id をクエリパラメータに入れて送信します。

GET /client_metadata_endpoint?client_id=1234 HTTP/1.1
Host: accounts.idp.example
Referer: https://rp.example/
Accept: application/json
Sec-Fetch-Dest: webidentity

IDP は、以下の形式でレスポンスを返します。

レスポンスパラメーター

parameter required note
privacy_policy_url RP のプライバシーポリシーのリンク
terms_of_service_url RP の利用規約のリンク
{
  "privacy_policy_url": "https://rp.example/clientmetadata/privacy_policy.html",
  "terms_of_service_url": "https://rp.example/clientmetadata/terms_of_service.html"
}

Step 5. Identiy Assertions エンドポイント

ここのエンドポイントは、サインインしているユーザーのアサーションを返します。

ブラウザは Content-Type: application/x-www-form-urlencoded として、cookie と パラメータを POST で送信します。

POST /id_assertion_endpoint HTTP/1.1
Host: accounts.idp.example
Referer: https://rp.example/
Content-Type: application/x-www-form-urlencoded
Cookie: 0x23223
Sec-Fetch-Dest: webidentity
account_id=123&client_id=client1234&nonce=Ct60bD&disclosure_text_shown=true

ここは、Context-Type: application/json ではないので注意!

IDP は以下のことを確認する必要があります

  • ユーザーによって選択されたアカウントID がすでにサインインしているID と一致しているか
  • Referer ヘッダーが 渡された ClientID に対して事前に登録されたオリジンと一致すること

An IDP MUST check the referrer to ensure that a malicious RP does not receive an ID token corresponding to another RP. In other words, the IDP MUST check that the referrer is represented by the client id. As the client ids are IDP-specific, the user agent cannot perform this check.
Reference: https://fedidcg.github.io/FedCM/#idp-api-id-assertion-endpoint

レスポンスパラメーター

parameter required note
token IDP が RP のログインするために必要なトークン

以上で IDP 側の実装は以上です。

RP の実装

RP の実装はとてもシンプルです。公式のドキュメントにもありますが、 navigator.credentials.get を呼ぶだけです。

const signinWithFedCM = async (
  context: ApiContext,
  params: SigninWithFedCMParams,
): Promise<Credential | null> => {
  if (typeof window === 'undefined') {
    // can't use on server side
    return null
  }
  if (!isFedCMEnabled()) {
    return null
  }

  return navigator.credentials.get({
    identity: {
      providers: [
        {
          configURL: params.configURL,
          clientId: params.clientId,
          nonce: params.nonce,
        },
      ],
    },
  })
}

https://github.com/knwoop/fedcm-example/blob/main/rp/src/clients/auth/signin-with-fedcm.ts#L27-L38

sever side では呼べないようにチェックもいれています。

リクエストパラメータ

parameter required note
configURL IDP 設定ファイルのパス
clientId IDP が発行した RP のクライアント識別子
nonce - リプレイス攻撃を防ぐための

あとちょっとしたポイントですが、 react で window オブジェクトの状態で component の出しわけする場合、SSR においてサーバサイドで React が生成した DOM と クライアント側でレンダリングおこなった DOM の結果がことなることで React Hydration Error が起こります。
Reference: https://nextjs.org/docs/messages/react-hydration-error

特に難しいことではないですが、以下のように seEffectuseState を用いて DOM の結果を同じにさせつつも、mount 後に コンポーネントの出し分けをするようにしれば解決します。

const SigninWithFedCMContainer = ({
  onSignin,
}: SigninWithFedCMContainerProps) => {
  const { signinWithFedCM, isFedCMEnabled } = useAuthContext()

  const [mounted, setMounted] = useState(false)

  const setGlobalSpinner = useGlobalSpinnerActionsContext()
  const handleSignin = async () => {
    const N = 16
    const nonce = randomBytes(N).toString('base64').substring(0, N)
    try {
      setGlobalSpinner(true)
      await signinWithFedCM(nonce)
      onSignin && onSignin()
    } catch (err: unknown) {
      if (err instanceof Error) {
        window.alert(err.message)
        onSignin && onSignin(err)
      }
    } finally {
      setGlobalSpinner(false)
    }
  }

  useEffect(() => {
    if (isFedCMEnabled()) {
      setMounted(true)
    }
  }, [isFedCMEnabled])

  if (!mounted) {
    return null
  }

  return (
    <Button variant={'secondary'} width="100%" onClick={() => handleSignin()}>
      FedCM demo
    </Button>
  )
}

https://github.com/knwoop/fedcm-example/blob/main/rp/src/containers/SigninWithFedCMContainer.tsx

サンプルコードでは RP 側で nonce のチェックをいれてないので、実際のコードではチェックをいれてください

最後に

実際に、API を利用して実装してみて RP 側の実装はそんなに難しくないといった印象でした。まだまだ主要なブラウザの実装が完了していないのをみると、まだまだ普及に時間がかかりそうですが、RP 側の実装が、こんなに簡単に実装できるなら取り入れる価値は十分にありそうです。FedCM の仕様が各ブラウザに実装されて、普及し始めれば、ソーシャルログインがかなり簡単にシンプルになるなと感じました。IDP の実装も一見多そうですがそこまで複雑ではないので、そこまでコストかけずに実装できるのかなと思いました。

やっぱり、トークンがフロントチャネルに行くのは気持ち悪さを感じるので今後どうなるのか楽しみです。このようなユーザー体験がよくなる仕様は積極的に取り入れていきたいとおもった次第でした。

reference

Discussion