1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust 認証ライブラリ oauth2-passkey で FedCM ログイン機能を実装した

1
Posted at

この記事は ktaka.blog.ccmp.jp の記事 のクロスポストです。

FedCM (Federated Credential Management)は、フェデレーション認証をブラウザネイティブのUIで行うためのW3C標準APIである。ポップアップウィンドウやリダイレクトを使わず、ブラウザ自身がアカウント選択画面を表示し、IDプロバイダーと直接通信する。

この記事では、FedCMの仕組み、実装方法、従来のOAuth2 Authorization Code Flowとの違い、そしてセキュリティ上のトレードオフを解説する。


FedCMとは

FedCM (Federated Credential Management)は、フェデレーション認証をブラウザネイティブのUIで行うためのAPIである。ポップアップウィンドウもリダイレクトも使わず、ブラウザ自身がアカウント選択画面を表示する。

従来のOAuth2フローでは、RP(あなたのアプリ)からGoogleのページにリダイレクトし、認証後にコールバックURLに戻ってくる必要があった。FedCMでは、navigator.credentials.get()を呼ぶだけで、ブラウザがGoogleと直接通信し、JWTのIDトークンを返す。

従来のOAuth2 Authorization Code Flow:
  ボタンクリック -> ポップアップ -> Google認証画面
    -> リダイレクト(認可コード付き)
    -> バックエンドが認可コードをGoogleと交換 (server-to-server)
    -> IDトークン取得 -> 検証 -> セッション確立

FedCM:
  ボタンクリック -> navigator.credentials.get()
    -> ブラウザがネイティブUIでアカウント選択画面を表示
    -> ブラウザがGoogleからJWT IDトークンを直接取得
    -> JSがトークンをバックエンドにPOST -> 検証 -> セッション確立

FedCMではブラウザが仲介役になるので、RPからロードしたJavaScriptはアカウントの一覧を見ることもできない。ブラウザがGoogleのセッションCookieを使って、ネイティブUIにアカウント情報を表示し、ユーザーが選択した結果だけがJavaScriptに返される。

FedCMのアカウント選択画面 従来のOAuth2ポップアップ
FedCMのブラウザネイティブUI 従来のOAuth2ポップアップ

実装方針

FedCMはブラウザの標準API (navigator.credentials.get())として提供されている。GoogleはGIS SDK経由での利用を推奨しているが、標準APIであるので、SDKなしで直接呼び出すことができる。

標準APIを直接使う利点は、ライブラリ依存が不要なことと、内部の動作を理解しやすいことである。何がどう動いているかを把握できれば、問題の切り分けも容易になる。

本記事では、oauth2-passkeyでの実装を例に、FedCMを標準APIとして直接扱う方法を説明する。

フロントエンドの実装

FedCMのフロントエンド実装は、大きく4つのステップである。

1. Nonceの取得

まず、サーバからnonceを取得する。このnonceはリプレイ攻撃を防ぐため、一度だけ使える値である。

const nonceResponse = await fetch('/api/fedcm/nonce');
const nonceData = await nonceResponse.json();
// { nonce: "ランダムな文字列", nonce_id: "キャッシュキー" }

2. navigator.credentials.get()の呼び出し

取得したnonceを使って、FedCMのAPIを呼び出す。

const credential = await navigator.credentials.get({
    identity: {
        providers: [{
            configURL: 'https://accounts.google.com/gsi/fedcm.json',
            clientId: OAUTH2_CLIENT_ID,
            params: {
                nonce: nonceData.nonce,
                response_type: 'id_token',
                scope: 'email profile openid',
                ss_domain: window.location.origin,
            },
        }],
        mode: 'active',
        context: 'signin',
    },
    mediation: 'required',  // 自動再認証を防ぎ、常にユーザー操作を要求
});

configURLはGoogleのFedCM設定ファイルのURLである。ブラウザはここから各エンドポイントの場所を取得する。params内のresponse_typescopess_domainはGoogle固有の要件で、FedCMのW3C仕様には含まれていない(後述)。ss_domainには実行環境のオリジン(例: https://passkey-demo.ccmp.jp)が入る。

mode: 'active'はユーザーのボタンクリックに応答する場合に使う。これがidentityオブジェクトの直下にあることが重要である。

mediation: 'required'は自動再認証を防ぐためのパラメーターである。これを指定することで、ブラウザは常にユーザーにアカウント選択を求める。

3. バックエンドへのトークン送信

取得したIDトークンとnonce_idをバックエンドに送信する。

const response = await fetch('/api/fedcm/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        token: credential.token,
        nonce_id: nonceData.nonce_id
    })
});

if (response.ok) {
    // ログイン成功、セッションクッキーがSet-Cookieヘッダーで設定される
    window.location.reload();
}

バックエンドはこのIDトークンを検証し、セッションを確立する。

4. OAuth2へのフォールバック

function openPopup() {
    if (isFedCMAvailable()) {
        fedcmLogin().catch(function(err) {
            console.log('FedCM failed, falling back to OAuth2 popup:', err.message);
            openPopupOAuth2();
        });
        return;
    }
    openPopupOAuth2();
}

FedCMが失敗した場合(非対応ブラウザ、ユーザーがダイアログを閉じた、エラーなど)は、Promiseの.catch()で従来のOAuth2ポップアップフローが自動的に開始される。

バックエンドの実装

バックエンドの処理は、大きく5つのステップである。

1. JWT検証(署名、iss/aud/exp、nonce)

フロントエンドから受け取ったIDトークンはJWT形式であり、署名検証とクレーム検証を行う。

fn validate_fedcm_token(token: String, nonce_id: String) -> IdInfo {
    // JWT署名検証、aud/iss/exp検証
    let idinfo = verify_idtoken(token, CLIENT_ID);

    // Nonce検証(一度だけ使えることを保証)
    verify_and_consume_nonce(nonce_id, idinfo.nonce);

    idinfo
}

JWT検証では以下を確認する。

  • 署名検証: GoogleのJWKS(公開鍵セット)を取得し、JWTヘッダーのkidで鍵を選び、署名を検証
  • クレーム検証: iss(発行者)、aud(Client ID)、exp(有効期限)が正しいかを確認
  • Nonce検証: IDトークン内のnonceクレームが、事前に生成したものと一致するかを確認し、検証後にキャッシュから削除することで再利用を防ぐ

GoogleのFedCMエンドポイントが返すJWTは、Authorization Code Flowで取得するIDトークンと同じ形式である。つまり、既存のOAuth2認証のJWT検証コードをそのまま再利用できる。

2. ユーザー情報の抽出

検証済みのIDトークンから、ユーザー情報を取り出す。

let user_info = UserInfo {
    email: idinfo.email,
    name: idinfo.name,
    provider_user_id: idinfo.sub,  // GoogleのユーザーID
};

IdInfoにはemailnamesub(プロバイダー内のユーザーID)などが含まれており、これを取り出して後続の処理で使う。

3. ユーザーの検索または作成

プロバイダーのユーザーIDでアカウントを検索し、存在しなければ新規作成する。

// プロバイダーのユーザーIDで既存アカウントを検索
let existing_user = db.find_user_by_provider_id(user_info.provider_user_id);

let user_id = match existing_user {
    Some(user) => {
        // 既存ユーザーでログイン
        user.id
    }
    None => {
        // 新規ユーザーを作成
        let new_user = db.create_user(user_info.email, user_info.name);
        db.link_provider(new_user.id, user_info.provider_user_id);
        new_user.id
    }
};

既存ユーザーが見つかればそのユーザーIDを使い、見つからなければ新規作成する。

4. セッションの作成

ユーザーIDが確定したら、セッションを作成する。

fn create_session(user_id: String) -> (String, Headers) {
    // セッションIDとCSRFトークンを生成
    let session_id = generate_random_string(32);
    let csrf_token = generate_random_string(32);
    let expires_at = now() + Duration::hours(1);

    // セッションをストアに保存
    let session = Session {
        user_id,
        csrf_token,
        expires_at,
    };
    cache.store(session_id, session, ttl: 24h);

    // Set-Cookieヘッダーを作成
    let headers = Headers::new();
    headers.set_cookie("session_id", session_id, expires_at);

    (session_id, headers)
}

セッションIDはランダムに生成され、キャッシュストア(Redisなど)に保存される。CSRFトークンもここで生成し、セッションと紐づけて保存する。

5. Set-Cookieヘッダーの返却

作成したセッションクッキーをSet-Cookieヘッダーとしてブラウザに返す。

// レスポンスを返す
return Response {
    status: 200,
    headers: headers,  // Set-Cookieヘッダーを含む
    body: json!({ "success": true }),
};

ブラウザはこのクッキーを次回のリクエストで自動的に送信し、認証状態が維持される。


認証後のユーザー処理(アカウントの検索・作成、セッションの発行、ログイン履歴の記録)は、OAuth2と共通の関数を使う。

セキュリティについて

FedCMはUXの改善をもたらすが、セキュリティモデルは従来のOAuth2/OIDCフローと異なる。

観点 Authorization Code Flow + PKCE FedCM
IDトークンの取得 サーバが認可コードを交換して取得 ブラウザが直接取得
JavaScriptがアクセスできる情報 認可コード(無価値) JWT IDトークン(認証に使える)
XSS攻撃のリスク 認可コードだけでは認証不可 有効期間内なら認証に使える

筆者の理解では、OAuth2 + PKCEの方がXSS攻撃に対する耐性は高いと考えている。なお、GoogleのOne Tap(GIS SDK)も同じモデルを採用している。

(セキュリティの評価について異なる見解や補足があれば、ぜひ教えてほしい)

FedCM APIを呼ぶ際の注意

navigator.credentials.get()に渡すJSONオブジェクトのパラメータには以下の注意が必要である。

Google固有の必須フィールド

GoogleのFedCMエンドポイントを使用する場合、paramsオブジェクト内に以下のパラメータが必要である。

フィールド 説明
response_type 'id_token' GoogleがJWTを返すために必要
scope 'email profile openid' 要求するスコープ
ss_domain window.location.origin RPのオリジン

mode: 'active'の記述位置

FedCMにはactive modeとpassive modeがある。active modeはユーザーのボタン操作に応答し、passive modeはページ読み込み時に自動表示する。

active modeを使う場合、mode: 'active'identityオブジェクトの直下に置く必要がある。providerオブジェクトの中に置くと、Chromeはそれを無視してpassive modeで動作する。エラーも出ない。

passive modeではユーザーがダイアログを閉じるとクールダウンが発生し、2時間から最長4週間FedCMが使えなくなる。active modeにはこのクールダウンがない。配置を間違えると、ユーザーが一度ダイアログを閉じただけでFedCMが長期間使えなくなる、という問題が起きる。

その他の注意点

JSONラップされたJWT: GoogleのFedCMエンドポイントは、JWTを{"token":"eyJ..."}というJSONでラップして返す場合がある。生のJWT文字列ではないので、パースが必要である。

nonceのparams移行: Chrome 143以降、nonceはproviderオブジェクトの直下ではなくparamsオブジェクト内に配置する必要がある。Chrome 145で旧形式は削除された。

ブラウザサポート

ブラウザ 対応状況
Chrome 108+ 対応
Edge 108+ 対応
Safari 非対応(ポップアップにフォールバック)
Firefox 非対応(ポップアップにフォールバック)

まとめ

FedCMの実装を通じて見えてきたこと:

  • FedCMのフロントエンドはnonce取得 → navigator.credentials.get() → バックエンドへPOSTの3ステップ。失敗時はOAuth2ポップアップに自動フォールバック
  • バックエンドはJWT検証 → ユーザー情報抽出 → ユーザー検索/作成 → セッション作成 → Set-Cookieの流れ。既存のJWT検証コードをそのまま使える
  • セキュリティモデルはAuthorization Code Flowと異なり、OAuth2 + PKCEの方がXSS攻撃への耐性は高い。FedCMはUX改善を優先したトレードオフ
  • navigator.credentials.get()のパラメータにはGoogle固有の要件があり、mode: 'active'の配置など正しい記述が重要

実装例はoauth2-passkeyを参照。デモサイトで実際の動作を試せる。

参考

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?