Cloudflare Workers + Hono で Firebase Auth認証を試したメモ

# 概要
CloudflareでホスティングしているサイトでFirebase Authenticationを使って認証を行う。今回は匿名認証を試す。

[ソースコード](https://github.com/hibohiboo/odyssage/blob/392bf347273df26489af60e4994cb577a1475100/apps/backend/src/index.ts)

# フロー
```mermaid
sequenceDiagram
    participant User as ユーザー
    participant Frontend as フロントエンド
    participant Firebase as Firebase Authentication
    participant Backend as バックエンドサーバー
    participant DB as データベース

    User->>Frontend: メール・パスワードを入力してログイン
    Frontend->>Firebase: Firebase SDKでログインリクエスト
    Firebase-->>Frontend: JWT（IDトークン）を発行
    Frontend->>Backend: JWTを含むAPIリクエスト<br>（例: Authorization: Bearer <JWT>）
    Backend->>Firebase: Admin SDKでJWTを検証
    Firebase-->>Backend: JWTが有効なら認証情報を返す
    Backend->>DB: 必要に応じてデータを取得
    DB-->>Backend: データを返却
    Backend-->>Frontend: APIレスポンスを返す
    Frontend-->>User: 認証成功後のデータを表示
```

# Firebaseのプロジェクト作成
Firebaseは利用できるようになっている前提とする。[*](https://firebase.google.com/docs/admin/setup)
[Firebase Console](https://console.firebase.google.com/)から「プロジェクトを作成する」を選択。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/723cb86a-7359-3c83-3983-75fe9a536ec9.png)

<details><summary>プロジェクトを作成するまでの記録</summary><div>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/83b9220d-5d2c-e1cb-8bd1-5c91a1e49c18.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/34f8315c-4eec-6611-ba77-79d7f33338e9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/b9132156-a73a-0e9b-fb3f-05c5534f5d0a.png)
新しいアナリティクスアカウントを作成。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/595182b7-78e8-9632-abb4-df5eb0690a45.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/342e74ba-53bf-6063-5652-12c596603296.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2a3703d5-89de-7716-780f-5abcd101dae6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/9f89ae4d-95e5-7407-57a5-c65dfc01cb0b.png)


</div></details>

<details><summary>Webアプリを登録してSDK用の設定を確認する</summary>

[Firebase を JavaScript プロジェクトに追加する](https://firebase.google.com/docs/web/setup)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/affb0044-17b4-47d9-17db-da91bc39dff6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/340a98b4-3f83-aeae-12c9-5db95d58a07f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/b934488a-25f1-1678-d269-d1363b8f9eae.png)

</details>

<details><summary>匿名認証を有効化する</summary>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/60e9469e-8117-2c74-d223-d03b3e4661c3.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/992c930b-34d5-4c48-21da-d24540e71f40.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/cd649f76-20b2-b751-101b-0534843e6bbc.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/61be5b78-02e1-2932-5cef-3739cc81e72b.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/5b6b602f-cce7-8a94-521c-ab6bcab3edf3.png)


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/ea195bf5-524c-bd45-48f7-3dc0655ee2f0.png)

</details>

[無料プランの制限](https://firebase.google.com/docs/auth)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/c58e4d15-9c90-f147-006a-c10fe6a34242.png)
# フロントエンド
[環境変数の設定](https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/a7635827-4e31-5d04-3d31-6de57f03b646.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/d4d7e3d0-7ad0-d2b6-9a32-50b53c2d2388.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2339ff50-9e73-6123-a750-b6b6f2d665ed.png)

環境変数は`.env`ファイルの内容をコピペすると簡単に入力できる。すごい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/be1a5265-d7cf-8e66-c2d8-a0136e43c804.png)

プレビューで確認したければ、環境を切り替えて同様に環境変数を設定する必要がある。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/befd6b14-d736-d182-dec0-e73c292ee3fa.png)

バックエンドとの通信のために、バックエンドのドメインを環境変数`VITE_BACKEND_DOMAIN`に追加する。
プレビューとプロダクションで値は異なるはず。これに間違えてフロントエンドのドメインを指定したところ、`405 Method Not Allowed`のエラーが発生した。指定間違いに注意。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/a2df7d82-4970-a7f5-b6c5-1f191a25a91d.png)

```ts:apps/frontend/src/shared/api/client.ts
import type { AppType } from '@odyssage/backend/index';
import { hc } from 'hono/client';
import { getIdToken } from '../lib/auth/firebaseAuth';
import { BACKEND_DOMAIN } from '../lib/config';

export const apiClient = hc<AppType>(BACKEND_DOMAIN, {
  headers: async () => ({ Authorization: `Bearer ${await getIdToken()}` }),
});
export type ApiClient = typeof apiClient;
```

```ts:apps/frontend/src/shared/lib/auth/firebaseAuth.ts
import {
  getAuth,
  NextOrObserver,
  onAuthStateChanged,
  signInAnonymously,
  User,
} from 'firebase/auth';
import { initializeApp } from 'firebase/app';

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string,
};
const firebaseApp = initializeApp(firebaseConfig);
const auth = getAuth(firebaseApp);

export const signInAnonymous = async () => {
  const ret = await signInAnonymously(auth);
  return ret;
};
export const onAuthStateChangedListener = (callback: NextOrObserver<User>) =>
  onAuthStateChanged(auth, callback);
export const getIdToken = async () => {
  const user = auth.currentUser;
  if (user == null) {
    return null;
  }
  return user.getIdToken();
};
```

```ts:apps/frontend/entities/auth/model/authSlice.ts
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';

export interface AuthState {
  uid: string | null;
  displayName: string | null;
}

const initialState: AuthState = {
  uid: null,
  displayName: null,
};

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setUser(
      state,
      action: PayloadAction<{ uid: string | null; displayName: string | null }>,
    ) {
      state.uid = action.payload.uid;
      state.displayName = action.payload.displayName;
    },
  },
});

export const { setUser } = authSlice.actions;

const stateSelector = (state: RootState) => state[authSlice.reducerPath];

export const userDisplayNameSelector = createSelector(
  stateSelector,
  (c) => c.displayName,
);
export const uidSelector = createSelector(stateSelector, (c) => c.uid);
```


```ts:apps/frontend/entities/auth/service/loginAction.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { apiClient } from '@odyssage/frontend/shared/api/client';
import {
  onAuthStateChangedListener,
  signInAnonymous,
} from '@odyssage/frontend/shared/lib/auth/firebaseAuth';
import { putHeaders } from '@odyssage/frontend/shared/lib/http/putHeader';
import { setUser } from '../model/authSlice';

export const loginAction = createAsyncThunk<
  void,
  void,
  { dispatch: AppDispatch; state: RootState }
>('loginAction', async (_, thunkAPI) => {
  onAuthStateChangedListener(async (user) => {
    if (!user) {
      thunkAPI.dispatch(setUser({ uid: null, displayName: null }));
      return;
    }
    thunkAPI.dispatch(
      setUser({ uid: user.uid, displayName: user.displayName }),
    );

    const result = await apiClient.api.user[':uid'].$get({
      param: { uid: user.uid },
    });
    if (result.status !== 404) return;
    console.log('Creating user');
    const ret = await apiClient.api.user[':uid'].$put(
      { param: { uid: user.uid } },
      { headers: putHeaders },
    );

    if (ret.status !== 204) {
      console.error('Failed to create user');
    }
  });
  await signInAnonymous();
});
```


# Cloudflare設定
バックエンドでKVを使うため設定。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/1466350b-d570-6b77-73a2-2bb77662f890.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/4cb48004-9606-9f64-fbba-0d37c721dd65.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/2abe2a9a-0e8b-19a5-6c8b-b7d9ccb6881d.png)


# バックエンド
`wrangler secret put FIREBASE_PROJECT_ID`で環境変数にfirebaseのProjectIdを追加。
[Hono FirebaseMiddleware](https://github.com/honojs/middleware/tree/main/packages/firebase-auth)のメンテ頻度が少ないため、[firebase-auth-cloudflare-workers](https://github.com/Code-Hex/firebase-auth-cloudflare-workers#workerskvstoresinglegetorinitializecachekey-string-cfkvnamespace-kvnamespace-workerskvstoresingle)を利用した。
[firebase-admin](https://github.com/firebase/firebase-admin-node?ref=deg84.com)だと、`Uncaught TypeError: globalThis.XMLHttpRequest is not a constructor`のエラーが出てしまうらしい。[*][*3]

```diff:apps/backend/wrangler.toml
# 省略
[observability]
enabled = true

+ [vars]
+ PUBLIC_JWK_CACHE_KEY = "odyssage-public-jwk-cache"

+ [[kv_namespaces]]
+ binding = "PUBLIC_JWK_CACHE_KV"
+ id = "<先ほど設定したKVのid>"
```

```diff:apps/backend/.dev.vars
NEON_CONNECTION_STRING=postgresql://<DB_USER_NAME>:<DB_PASSWORD>@<NEON_DB_DOMAIN>.us-west-2.aws.neon.tech/main?sslmode=require
NEO4J_URL=neo4j+s://<NEO4J_INSTANCE_DOMAIN>.databases.neo4j.io
NEO4J_USER=neo4j
NEO4J_PASSWORD=<NEO4J_PASSWORD>

+ FIREBASE_PROJECT_ID=hogehoge
```

```ts:apps/bakend/src/utils/verifyJWT.ts
import { Auth, WorkersKVStoreSingle } from 'firebase-auth-cloudflare-workers';

import type { EmulatorEnv, FirebaseIdToken } from 'firebase-auth-cloudflare-workers';

interface Bindings extends EmulatorEnv {
	FIREBASE_PROJECT_ID: string;
	PUBLIC_JWK_CACHE_KEY: string;
	PUBLIC_JWK_CACHE_KV: KVNamespace;
	FIREBASE_AUTH_EMULATOR_HOST: string;
}

export const verifyJWT = async (authorization: string | null | undefined, env: Bindings): Promise<FirebaseIdToken | null> => {
	if (authorization == null) {
		console.warn('No authorization header');
		return null;
	}
	const jwt = authorization.replace(/Bearer\s+/i, '');
	const auth = Auth.getOrInitialize(
		env.FIREBASE_PROJECT_ID,
		WorkersKVStoreSingle.getOrInitialize(env.PUBLIC_JWK_CACHE_KEY, env.PUBLIC_JWK_CACHE_KV),
	);

	return auth.verifyIdToken(jwt, false, env);
};
```

```ts:apps/backend/src/middleware/authorizeMiddlware.ts
import { createMiddleware } from 'hono/factory';
import { verifyJWT } from '../utils/verifyJWT';

// eslint-disable-next-line consistent-return
export const authorizeMiddleware = createMiddleware(async (c, next) => {
	const token = await verifyJWT(c.req.header('Authorization'), c.env);

	if (token == null) {
		return c.body('Unauthorized', 401);
	}

	await next();
});

```

```ts:apps/backend/src/route/index.ts
// 省略
const route = new Hono<Env>()
	.get('/', (c) => c.text('Hello Cloudflare Workers!'))
	.use('/user/*', authorizeMiddleware)
    .route('/user', user)
 ```

```ts:apps/backend/src/index.ts
// 省略
const app = new Hono<Env>()
	.use(
		'/api/*',
		cors({
			origin: ['https://odyssage.pages.dev', 'http://localhost:5173'],
			allowMethods: ['GET', 'PUT', 'OPTIONS'],
			allowHeaders: ['Content-Type', 'Authorization'],
		}),
	)
    .route('/api', route);
```

ここで`origin: ['*'],`とすると、Cloudflareでは`strict-origin-when-cross-origin`のエラーがでた。
ローカルでは発生しないので注意。

なお、verifyJWTが返すトークンは下記のようなトークンが返ってくる。

```json:token
{
  "provider_id": "anonymous",
  "iss": "https://securetoken.google.com/hogehoge",
  "aud": "hogehoge",
  "auth_time": 1735908952,
  "user_id": "*****************ZZ99",
  "sub": "*****************ZZ99",
  "iat": 1735920914,
  "exp": 1735924514,
  "firebase": { "identities": {}, "sign_in_provider": "anonymous" },
  "uid": "*****************ZZ99"
}
```

[公開したページ](https://odyssage.pages.dev/)


# 参考
公式

[ウェブサイトで Firebase Authentication を使ってみる][*g1]
[JavaScript でカスタム認証システムを使用して Firebase 認証を行う ][*g2]

[*g1]:https://firebase.google.com/docs/auth/web/start
[*g2]:https://firebase.google.com/docs/auth/web/custom-auth

[Firebase Auth の 匿名認証 で爆速オンボーディング！ユーザー離脱を防ぐベストプラクティス](https://zenn.dev/cloud_ace/articles/firebase-anonymous-auth-best-practices)

バックエンド

[Cloudflare Workers でも Firebase Authentication を使えるぞ！！][*1]  
[Hono, Firebase AuthでToken検証してみる。@hono/firebase-authを使ってみよう！Cloudflare workersで定義する関数に認証チェックをつける][*3]  
[Cloudflare WorkersでFirebase Authのトークン検証を実装する][*2]   
[Firebase Authenticationの認証でCloudflareを利用してFirebase Hostingへのカスタムドメイン登録を省略する][*4]  
[【Cloudflare Pages/D1】個人開発のためのローリスクなクラウドを探し求めてCloudflareに出会う【Next.js】][*5]  
[odyssage - issue](https://github.com/hibohiboo/odyssage/issues/25)

[*1]:https://zenn.dev/codehex/articles/ca85a1babcc046
[*2]:https://www.deg84.com/cloudflare-workers-firebase-auth-token-verification/
[*3]:https://www.deg84.com/cloudflare-workers-firebase-auth-token-verification/
[*4]:https://qiita.com/yuta-katayama-23/items/0a730b5b8499384aa888
[*5]:https://qiita.com/Hanull/items/8746c7fb41779efeffa6

[HonoでCORS対応をする](https://zenn.dev/camomile_cafe/articles/b11a3f9b8f2f1d)

過去にFirebaseを触った記事

[react-redux-firebase を導入しようとしたときに起きた@@reactReduxFirebase/LOGINのエラーを消したメモ](https://qiita.com/hibohiboo/items/02b000502ae9f72873fb)
[Zeit Now + Next.js のページにFirebase認証を導入した際、環境変数にハマったメモ]( https://qiita.com/hibohiboo/items/6edf851b3a5dc6601b37)
[Laravel6.0 の認証を Firebase のTwitter Oauth認証で行ったメモ](https://qiita.com/hibohiboo/items/11768cb10bf1e7025090 )
[firebase9に更新したら型のエラーがでたので対応したメモ](https://qiita.com/hibohiboo/items/9fe72a434552949270a5)
[ワンコイン/月で作るゲームブックプラットフォームの使用技術メモ]( https://qiita.com/hibohiboo/items/123633cd0ba829ff4d89)