🦔

Firebase Custom Claims(TypeScript)

2024/11/24に公開

Firebase で Custome Claimsを作ったときのメモ

要約

  • 2種類のシンプルなカスタムクレームを設定
    • 特権管理者: Admin
    • 一般ユーザー: Member
  • 自分自身の権限を設定する処理を実装
  • 作成時にデフォルトのユーザー権限を付与
  • 権限一覧を取得する機能を実装

Frontend は Vue3 + piniaで構築

package.json

Frontend

package.json
{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --host",
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "firebase": "^11.0.2",
    "pinia": "^2.2.6",
    "vue": "^3.5.13",
    "vue-router": "^4.4.5"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.0",
    "typescript": "~5.6.3",
    "vite": "^5.4.11",
    "vue-tsc": "^2.1.10"
  }
}

Backend

package.json
{
  "name": "functions",
  "scripts": {
    "lint": "eslint --ext .js,.ts .",
    "build": "tsc",
    "build:watch": "tsc --watch",
    "serve": "npm run build && firebase emulators:start --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "18"
  },
  "main": "lib/index.js",
  "dependencies": {
    "cors": "^2.8.5",
    "firebase-admin": "^12.6.0",
    "firebase-functions": "^6.0.1"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^5.12.0",
    "@typescript-eslint/parser": "^5.12.0",
    "eslint": "^8.9.0",
    "eslint-config-google": "^0.14.0",
    "eslint-plugin-import": "^2.25.4",
    "firebase-functions-test": "^3.1.0",
    "typescript": "^4.9.0"
  },
  "private": true
}

自分自身の権限を設定する処理を実装

Backend

  1. Authorizationヘッダーの確認
  2. トークンの抽出と検証
  3. 必須パラメータの検証
  4. カスタムクレームの設定
index.ts
/**
 * ユーザーにカスタムクレームを設定する Cloud Function
 * HTTP POST リクエストで呼び出します。
 */
export const setUserRole = functions.https.onRequest(async (req, res) => {
    try {
      // Authorization ヘッダーを取得
      const authHeader = req.headers.authorization;

      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        res.status(401).send({ error: 'Unauthorized: No token provided' });
        return;
      }

      // トークンを抽出
      const idToken = authHeader.split('Bearer ')[1];

      // トークンを検証してデコード
      const decodedToken = await admin.auth().verifyIdToken(idToken);

      // リクエスト送信者の UID を取得
      const { uid } = decodedToken;
      console.log(`Logged-in user UID: ${uid}`);

      // // デコードされたトークンから role を取得し、管理者であることを確認
      // if (decodedToken.role !== "Admin") {
      //   res.status(403).send({ error: "Forbidden: Only Admin can set roles." });
      //   return;
      // }
      const { role } = req.body;

      if (!uid || !role) {
        res.status(400).send({ error: 'uid and role are required' });
        return;
      }

      if (!['Admin', 'Member'].includes(role)) {
        res.status(400).send({ error: 'Invalid role' });
        return;
      }

      // Firebase Authentication のカスタムクレームを設定
      await admin.auth().setCustomUserClaims(uid, { role });
      res.status(200).send({ message: `Successfully set role '${role}' for user '${uid}'` });
    } catch (error) {
      console.error('Error setting custom claims:', error);
      res.status(500).send({ error: 'Failed to set custom claims' });
    }
});

Frontend

updateUserRole ログインユーザーを取得して roleを設定
fetchUserRole トークンを手動更新して、新しいRoleを取得

useAuth.ts
async updateUserRole(role: string) {
  try {
    const user = getAuth().currentUser;
    if (!user) {
      throw new Error('User is not logged in.');
    }

    const idToken = await user.getIdToken();
    const response = await fetch('https://<-your api->.a.run.app', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${idToken}`,
      },
      body: JSON.stringify({ role }),
    });

    if (response.ok) {
      // updateUserRole APIのレスポンスに合わせて型を定義
      const data = (await response.json()) as { message: string };
      console.log('Success:', data.message);
      await this.fetchUserRole(user);
    } else {
      const error = (await response.json()) as { error: string };
      console.error('Error:', error.error);
      alert(error.error);
    }
  } catch (error) {
    this.handleError(error as AuthError);
  }
},
async fetchUserRole(user: User) {
  try {
    await user.getIdToken(true); // トークンを更新
    const idTokenResult = await user.getIdTokenResult();
    this.role = idTokenResult.claims.role as 'Admin' | 'Member' | undefined;
  } catch (error) {
    this.handleError(error as AuthError);
  }
}

作成時にデフォルトのユーザー権限を付与

Backend

これが今回の引っ掛かりポイントでした。
通常のimportではv1が適用されるとのことでしたが、私の環境ではv2になっていてauthが使えませんでした。

  1. 第2世代は作成時のイベントとトリガーがサポートれていません。第 1 世代と第 2 世代の関数は同じソースファイルで共存できるので明示的にimportします。
  2. Memberを付与
index.ts
import * as functionsv1 from 'firebase-functions/v1';
...

export const setDefaultUserRole = functionsv1.auth.user().onCreate(async (user) => {
  try {
    await admin.auth().setCustomUserClaims(user.uid, { role: 'Member' });
    console.log(`Successfully set role 'Member' for user '${user.uid}'`);
  } catch (error) {
    console.error('Error setting custom claims:', error);
  }
});

Frontend

サインインでユーザーを取得して使うのがセオリーだと思いますが、
この時点ではsetDefaultUserRoleが未完了でトークンがリフレッシュされていないので、
onAuthStateChangedを利用して更にトークンリフレッシュして権限を取得する。
確実な良い方法はちょっとわからない。

  1. signInWithGoogle サインイン
  2. onAuthStateChanged ログイン確認
useAuth.ts
async signInWithGoogle(): Promise<boolean> {
  try {
    if (this.isLoggedIn) {
      await this.signOut();
    }

    const auth = getAuth();
    const provider = new GoogleAuthProvider();
    await signInWithPopup(auth, provider);
    // このresultではカスタムクレームがまだついていない。
    // const result = await signInWithPopup(auth, provider);

    // await this.setUser(result.user);
    return true;
  } catch (error) {
    this.handleError(error as AuthError);
    return false;
  }
},
async onAuthStateChanged() {
  const auth = getAuth();
  return new Promise<User | null>((resolve, reject) => {
    onAuthStateChanged(
      auth,
      async (user) => {
        if (user) {
          await this.setUser(user);
        }
        resolve(user);
      },
      (error) => {
        this.handleError(error as AuthError);
        reject(error);
      },
    );
  });
}

権限一覧を取得する機能を実装

Backend

  1. Authorizationヘッダーの確認
  2. トークンの抽出と検証
  3. クエリパラメータの取得、検証
  4. Firebase Authenticationからユーザー情報の取得
  5. レスポンスの整形
index.ts
export const getUserClaims = functions.https.onRequest(async (req, res) => {
    try {
      // Authorization ヘッダーを取得
      const authHeader = req.headers.authorization;
    
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        res.status(401).send({ error: 'Unauthorized: No token provided' });
        return;
      }
    
      // トークンを抽出
      const idToken = authHeader.split('Bearer ')[1];
    
      // トークンを検証してデコード
      const decodedToken = await admin.auth().verifyIdToken(idToken);
      
      // リクエスト送信者の UID を取得
      const { uid } = decodedToken;
      console.log(`Logged-in user UID: ${uid}`);
    
      // // 管理者権限を確認 (必要に応じて)
      // if (decodedToken.role !== 'Admin') {
      //   res.status(403).send({ error: 'Forbidden: Only Admin can retrieve users.' });
      //   return;
      // }
    
      // クエリパラメータから limit と nextPageToken を取得
      const limit = parseInt(req.query.limit as string) || 100;
      const nextPageToken = req.query.nextPageToken as string | undefined;
    
      if (limit < 1 || limit > 100) {
        res.status(400).send({ error: 'Limit must be between 1 and 100' });
        return;
      }
    
      // Firebase Authentication からユーザーを取得
      const listUsersResult = await admin.auth().listUsers(limit, nextPageToken);
      const usersWithClaims = await Promise.all(
        listUsersResult.users.map(async (user) => {
          const customClaims = user.customClaims || {};
          return {
            uid: user.uid,
            email: user.email,
            role: customClaims.role || '',
          };
        })
      );
    
      res.status(200).send({
        users: usersWithClaims,
        nextPageToken: listUsersResult.pageToken || null, // 次のページトークンを返す
      });
    } catch (error) {
      console.error('Error retrieving users:', error);
      res.status(500).send({ error: 'Failed to retrieve users' });
    }
});

Frontend

fetchUsers limitとトークンを受け取りAPIをコールする

useAuth.ts
async fetchUsers(limit = 100, nextPageToken: string | null = null) {
  try {
    const user = getAuth().currentUser;
    if (!user) {
      throw new Error('User is not logged in.');
    }

    const idToken = await user.getIdToken();
  
    const queryParams = { limit, ...(nextPageToken && { nextPageToken }) };
    
    const response = await fetch(
      `https://<-your api->.a.run.app?${queryParams.toString()}`,
      {
        method: 'GET',
        headers: {
          Authorization: `Bearer ${idToken}`,
        },
      }
    );

    if (!response.ok) {
      throw new Error(`Error: ${response.status} ${response.statusText}`);
    }

    const data = await response.json();
    if (nextPageToken === null) {
      this.users.length = 0;
    }
    this.users = [...this.users, ...data.users];
    this.nextPageToken = data.nextPageToken || null;
  } catch (error) {
    this.handleError(error as AuthError);
  }
}

感想

Cloud FunctionsをTypeScriptで書いている事例が少なく、生成AIに質問しても適切な回答が得られず、無駄に時間を溶かした。
トークンのリフレッシュタイミングが正確に把握できれば良いが、よくある事例ではDBとの組み合わせで管理する方法が一般的なようだ。
ただし、この方法ではI/Oが増えるため、より良い解決策を知りたい。

Discussion