🦔
Firebase Custom Claims(TypeScript)
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
- Authorizationヘッダーの確認
- トークンの抽出と検証
- 必須パラメータの検証
- カスタムクレームの設定
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が使えませんでした。
- 第2世代は作成時のイベントとトリガーがサポートれていません。第 1 世代と第 2 世代の関数は同じソースファイルで共存できるので明示的にimportします。
- 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を利用して更にトークンリフレッシュして権限を取得する。
確実な良い方法はちょっとわからない。
- signInWithGoogle サインイン
- 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
- Authorizationヘッダーの確認
- トークンの抽出と検証
- クエリパラメータの取得、検証
- Firebase Authenticationからユーザー情報の取得
- レスポンスの整形
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