Closed28

Cloudflare Pages edge ランタイムの Web で Firebase Authentication の認証を Auth.js でする

9sako69sako6

構成

  • Next.js (App router)
  • Cloudflare Pages
  • Firebase Authentication

問題

今まではクライアントサイドでのみ認証をしていたが、サーバーサイドでも行いたい。
Firebase Authentication の基本に従うとクライアントサイドでのみ認証ができるようになる。
Auth.js (NextAuth.js) と Firebase admin SDK を使用し、Auth.js の方でセッションを管理すればサーバーサイドでも認証できる。Auth.js, Firebase Authentication を組み合わせて使う場合は通常、Firebase の Node.js 用ライブラリであるところの Firebase admin SDK を使用する。

しかし、フロントエンドは Cloudflare Pages で動かしており、Firebase admin SDK は edge ランタイムでは動かないので別の方法を探す必要がある。

  1. Auth.js で Firebase Admin SDK が必要なのはトークンの verify 兼ユーザー情報取得のためだけの認識なので、Cloud Functions を作って(もしくは、Google REST API を使って)、fetch で動くようにする。

  2. next-firebase-auth-edge を使う

9sako69sako6

Auth.js を試す

公式ドキュメントに沿って設定していく。
https://authjs.dev/getting-started/installation?framework=next.js

Auth.js v5 からは edge ランタイムに対応しているはず。

https://authjs.dev/getting-started/migrating-to-v5#edge-compatibility

下記記事を参考にさせてもらいました。
https://zenn.dev/psi/articles/app-router-firebase-authentication

npm install next-auth@beta

ボタンの onClick で下記を呼び出す。

const logInWithGoogle = async () => {
  const auth = getAuth();
  const provider = new GoogleAuthProvider();
  const userCredential = await signInWithPopup(auth, provider);
  const refreshToken = userCredential.user.refreshToken;
  const idToken = await userCredential.user.getIdToken();
  await signInAuthJs('credentials', { idToken, refreshToken, callbackUrl: '/' });
};

現時点で未解決のこの issue と同じ現象に見舞われた。

https://github.com/nextauthjs/next-auth/issues/11076

  • next@14.2.3
  • next-auth@5.0.0-beta.19
  • firebase@10.12.2
9sako69sako6

Emulator はこれでいけた。User 情報返ってきた。

const res = await fetch(
  `http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:lookup?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`,
  {
    method: 'POST',
    body: JSON.stringify({ idToken }),
    headers: {
      'Content-Type': 'application/json',
    },
  },
);

// debug
console.log({ res });
const json = await res.json();

console.log({ json });
console.log({ users: json.users });
response
{
  res: Response {
    [Symbol(realm)]: null,
    [Symbol(state)]: {
      aborted: false,
      rangeRequested: false,
      timingAllowPassed: true,
      requestIncludesCredentials: true,
      type: 'default',
      status: 200,
      timingInfo: [Object],
      cacheState: '',
      statusText: 'OK',
      headersList: [HeadersList],
      urlList: [Array],
      body: [Object]
    },
    [Symbol(headers)]: HeadersList {
      cookies: null,
      [Symbol(headers map)]: [Map],
      [Symbol(headers map sorted)]: null
    }
  }
}
{
  json: {
    kind: 'identitytoolkit#GetAccountInfoResponse',
    users: [ [Object] ]
  }
}
{
  users: [
    {
      localId: 'MH28PUIR8nb7un3Gi5FazIk2YyLP',
      createdAt: '1714326785845',
      lastLoginAt: '1720495539465',
      displayName: 'Jane',
      providerUserInfo: [Array],
      validSince: '1720437741',
      email: 'mountain.panda.905@example.com',
      emailVerified: true,
      disabled: false,
      lastRefreshAt: '2024-07-09T03:25:39.465Z'
    }
  ]
}
9sako69sako6

Client Component のボタンから onClick で呼ぶメソッド。

import { signIn as signInByNextAuth } from 'next-auth/react';
import { GoogleAuthProvider, getAuth, signInWithPopup } from 'firebase/auth';

const logInWithGoogle = async () => {
  const auth = getAuth();
  const provider = new GoogleAuthProvider();
  const userCredential = await signInWithPopup(auth, provider);
  const refreshToken = userCredential.user.refreshToken;
  const idToken = await userCredential.user.getIdToken();
  await signInByNextAuth('credentials', { idToken, refreshToken, callbackUrl: '/' });
};

NextAuth の設定は下記で Cookie に session をつけることができた。
Firebase Admin SDK を使うことなく Google REST API で済んだ。
Firebase Emulator でもちゃんと動いている。FIREBASE_API_KEY は露出しても問題ない情報です。

src/app/api/auth/[...nextauth]/route.ts
src/app/api/auth/[...nextauth]/route.ts
import NextAuth, { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { z } from 'zod';

declare module 'next-auth' {
  // NOTE: authorize の返り値であり、jwt callback に渡される user オブジェクトの型
  interface User {
    id: string;
    name: string;
    emailVerified: boolean;
    idToken: string;
    refreshToken: string;
    // NOTE: Expiration time (seconds since the Unix epoch)
    tokenExp: number;
  }

  interface Session {
    idToken: string;
    user: {
      id: string;
      name: string;
    };
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id?: string;
    name?: string;
    idToken?: string;
    refreshToken?: string;
    // NOTE: Expiration time (seconds since the Unix epoch)
    exp?: number;
  }
}

const DecodedUser = z.object({
  localId: z.string(),
  displayName: z.string(),
  emailVerified: z.boolean(),
  disabled: z.boolean(),
});

const getTimeInUnixEpochSeconds = () => Math.floor(Date.now() / 1000);

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      credentials: {},
      async authorize({ idToken, refreshToken }: any, _req) {
        if (!idToken || !refreshToken) {
          return null;
        }

        try {
          console.log('in CredentialsProvider', {
            idToken,
            refreshToken,
          });
          const res = await fetch(
            `http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:lookup?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`,
            {
              method: 'POST',
              body: JSON.stringify({ idToken }),
              headers: {
                'Content-Type': 'application/json',
              },
            },
          );

          if (!res.ok) {
            throw new Error('Failed to fetch accounts');
          }
          const json = await res.json();
          const payload = DecodedUser.parse(json.users[0]);

          const user = {
            id: payload.localId,
            name: payload.displayName,
            emailVerified: payload.emailVerified,
            idToken,
            refreshToken,
            // NOTE: すぐ後のプロセスでリフレッシュトークンを使って更新する。
            tokenExp: getTimeInUnixEpochSeconds(),
          };

          return user;
        } catch (error) {
          console.error(error);
          return null;
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user, account, profile, isNewUser, trigger }) {
      if (user) {
        token.id = user.id;
        token.name = user.name;
        token.idToken = user.idToken;
        token.refreshToken = user.refreshToken;
        token.exp = user.tokenExp;
      }
      if (!token.exp) {
        token.exp = getTimeInUnixEpochSeconds();
      }

      try {
        // NOTE: 期限切れまで5分(300秒)を切ったらトークンを更新する
        const isExpired = token.exp - getTimeInUnixEpochSeconds() < 300;
        if (isExpired) {
          const res = await fetch(
            `http://localhost:9099/securetoken.googleapis.com/v1/token?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`,
            {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                grant_type: 'refresh_token',
                refreshToken: token.refreshToken,
              }),
            },
          );

          if (!res.ok) {
            throw new Error('Failed to refresh token');
          }

          const json = await res.json();

          const { refresh_token: refreshToken, id_token: idToken, expires_in: expiresIn } = json;
          // NOTE: expiresIn は期限切れまでの秒数(3600秒)
          token.refreshToken = refreshToken;
          token.idToken = idToken;
          token.exp = getTimeInUnixEpochSeconds() + expiresIn;
        }
      } catch (error) {
        console.error(error);
      }
      return token;
    },
    async session({ session, token }) {
      session.user.id = token.id || '';
      session.user.name = token.name || '';
      session.idToken = token.idToken || '';
      // NOTE: The return type will match the one returned in `useSession()`
      return session;
    },
  },
  session: {
    strategy: 'jwt',
    maxAge: 180 * 24 * 60 * 60, // 180 days
  },
  secret: process.env.NEXTAUTH_SECRET,
} satisfies NextAuthOptions);

export { handler as GET, handler as POST };

(後述)
上記のコード、Auth.js v5 にした際にちょっと変わったので後で v5 版も貼る。

9sako69sako6

しかし、結局 v5 で実装しないと edge 対応できない。v4 の実装をもとに v5 での issue をどうにか解決したい。

Error: headers was called outside a request scope

デバッグしていた結果、next-auth/reactsignIn を使うと問題ないが、v5 の auth.ts から import した signIn を使うと問題が起きることがわかった。next-auth/react を使うことで問題が起こるかはわからない。

// ok
import { signIn } from 'next-auth/react';
// ng
import { signIn } from '@/auth';

この辺のせいでは?

https://github.com/nextauthjs/next-auth/blob/1bd4dd8316e33d14636e5345fc24e4ef380f4789/packages/next-auth/src/lib/index.ts#L188

あー、auth.ts の方は Server actions で signIn を呼ぶ前提だった。

9sako69sako6

Cloudflare Pages の環境変数に AUTH_TRUST_HOST, AUTH_SECRET をセットした。

9sako69sako6

デプロイしてログインできた。
__Secure-authjs.session-token という cookie が付与されている。

Client Component で useSession した結果も得られる。

時代は edge だね。

9sako69sako6

Firebase SDK はデフォルトでブラウザに認証情報を保存するが、それをやめる設定をしておく。
デフォルト状態だと明示的に Firebase SDK でログアウトしない限り認証状態が残り、Firebase SDK と Auth.js 両方でログアウト処理をしなければならず複雑になる。

const auth = getAuth();
auth.setPersistence(inMemoryPersistence);

状態はメモリにのみ保存され、ウィンドウまたはアクティビティが更新されるとクリアされることを示します。

https://firebase.google.com/docs/auth/web/auth-state-persistence?hl=ja#web-modular-api


(と思ったが)
Firebase Authentication 側でもログイン状態を保持しておかないと、Firestore へのリクエストに request.auth が乗らなくなる。下記のようにセキュリティルールで認証ができなくなる。このデメリットを思うと、毎回 Firebase Auth & Auth.js 両方ログアウトする方がいい。

match /users/{userId} {
  allow read: if request.auth.uid == userId;
}
9sako69sako6

Auth.js v5 を edge (Cloudflare Pages) 対応したもの。

src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@nztk/auth';

export const runtime = 'edge';

export const { GET, POST } = handlers;

src/auth.ts
import NextAuth from 'next-auth';
import { JWT as _ } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import { z } from 'zod';

declare module 'next-auth' {
  // NOTE: The type of the user object returned by the `authorize` and passed to the `jwt` callback
  interface User {
    emailVerified: boolean;
    // NOTE: OpenID ID Token from Firebae Authentication
    idToken: string;
    refreshToken: string;
    // NOTE: Expiration time (seconds since the Unix epoch)
    idTokenExp: number;
  }

  interface Session {
    // NOTE: OpenID ID Token from Firebae Authentication
    idToken: string;
  }
}

declare module 'next-auth/jwt' {
  // NOTE: Returned by the `jwt` callback and `auth`, when using JWT sessions
  interface JWT {
    // NOTE: OpenID ID Token from Firebae Authentication
    idToken?: string;
    refreshToken?: string;
    // NOTE: Expiration time (seconds since the Unix epoch)
    idTokenExp?: number;
  }
}

const DecodedUser = z.object({
  localId: z.string(),
  displayName: z.string(),
  emailVerified: z.boolean(),
});

const getTimeInUnixEpochSeconds = () => Math.floor(Date.now() / 1000);

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [
    CredentialsProvider({
      async authorize({ idToken, refreshToken }: any, _req) {
        if (!idToken || !refreshToken) {
          return null;
        }

        try {
          console.log('in CredentialsProvider', {
            idToken,
            refreshToken,
          });
          const url = process.env.NEXT_PUBLIC_USE_FIREBASE_EMULATOR
            ? `http://${process.env.NEXT_PUBLIC_FIREBASE_EMULATOR_HOST}:${process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT}/identitytoolkit.googleapis.com/v1/accounts:lookup?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`
            : `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`;
          const res = await fetch(url, {
            method: 'POST',
            body: JSON.stringify({ idToken }),
            headers: {
              'Content-Type': 'application/json',
            },
          });

          if (!res.ok) {
            throw new Error('Failed to fetch accounts');
          }

          const json = await res.json();
          const payload = DecodedUser.parse(json.users[0]);

          const user = {
            id: payload.localId,
            name: payload.displayName,
            emailVerified: payload.emailVerified,
            idToken,
            refreshToken,
            // NOTE: すぐ後のプロセスでリフレッシュトークンを使って更新する。
            idTokenExp: getTimeInUnixEpochSeconds(),
          };

          return user;
        } catch (error) {
          console.error(error);
          return null;
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user, account, profile, isNewUser, trigger }) {
      if (user) {
        token.id = user.id;
        token.name = user.name;
        token.idToken = user.idToken;
        token.refreshToken = user.refreshToken;
        token.idTokenExp = user.idTokenExp;
      }
      if (!token.idTokenExp) {
        token.idTokenExp = getTimeInUnixEpochSeconds();
      }

      try {
        // NOTE: 期限切れまで5分(300秒)を切ったらトークンを更新する
        const isExpired = token.idTokenExp - getTimeInUnixEpochSeconds() < 300;
        if (isExpired) {
          const url = process.env.NEXT_PUBLIC_USE_FIREBASE_EMULATOR
            ? `http://${process.env.NEXT_PUBLIC_FIREBASE_EMULATOR_HOST}:${process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT}/securetoken.googleapis.com/v1/token?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`
            : `https://securetoken.googleapis.com/v1/token?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`;

          const res = await fetch(url, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              grant_type: 'refresh_token',
              refreshToken: token.refreshToken,
            }),
          });

          if (!res.ok) {
            console.error(res);
            throw new Error('Failed to refresh token');
          }

          const json = await res.json();
          const { refresh_token: refreshToken, id_token: idToken, expires_in: expiresIn } = json;
          // NOTE: expiresIn は期限切れまでの秒数(3600秒)
          token.refreshToken = refreshToken;
          token.idToken = idToken;
          token.idTokenExp = getTimeInUnixEpochSeconds() + Number(expiresIn);
        }
      } catch (error) {
        console.error(error);
      }

      return token;
    },
    async session({ session, token }) {
      session.user.id = typeof token.id === 'string' ? token.id : '';
      session.user.name = token.name || '';
      session.idToken = typeof token.idToken === 'string' ? token.idToken : '';
      // NOTE: The return type will match the one returned in `useSession()`
      return session;
    },
  },
  session: {
    strategy: 'jwt',
    maxAge: 180 * 24 * 60 * 60, // 180 days
  },
  secret: process.env.NEXTAUTH_SECRET,
});

9sako69sako6

テストでエラーになる。

  • jest@29.7.0
  • next-auth@5.0.0-beta.19
jest.mock('next-auth/react');
Cannot find module 'next-auth/react' from 'src/app/...

https://github.com/nextauthjs/next-auth/discussions/9880

moduleNameMapper で解決した。

jest/next-auth-react.ts
const NextAuthReact = () => ({
  useSession: jest.fn(),
});

export default NextAuthReact;

jest.conif.js
      moduleNameMapper: {
        '@/(.*)$': '<rootDir>/src/$1',
        'next-auth/react': '<rootDir>/jest/next-auth-react.ts'
      },
9sako69sako6

ローカルではいらなかったが、CI 上では GitLab CI の services エイリアスを使っているせいかエラーになった。

[ERROR] [auth][error] UntrustedHost: Host must be trusted. URL was: https://web:3000/api/auth/session. Read more at https://errors.authjs.dev#untrustedhost

https://errors.authjs.dev#untrustedhost

auth.ts の NextAuth 初期化時に trustHost: true オプションを渡すようにした。

9sako69sako6

次のエラー。Next.js には AUTH_SECRET を渡しているはず、渡せてないのか

[ERROR] [auth][error] MissingSecret: Please define a `secret`.. Read more at https://errors.authjs.dev#missingsecret

CI 上では wrangler pages dev で Web を立ち上げている。環境変数を渡すには --binding オプションが必要だった。

https://developers.cloudflare.com/pages/functions/bindings/#interact-with-your-environment-variables-locally

CMD ["wrangler", "pages", "dev", \
  ".vercel/output/static", \
  "--binding=AUTH_SECRET=dummy", \
  "--compatibility-flag=nodejs_compat", \
  "--compatibility-date=2024-05-12", \
  "--ip=0.0.0.0", \
  "--port=3000"]

9sako69sako6

Playwright の E2E テストを通すのに苦労している。
Web を立ち上げてすぐの状態で、ログイン -> トップにリダイレクト -> await page.goto('/example') のように遷移したい。
しかし、ログイン -> トップにリダイレクト -> await page.goto('/example') -> トップにリダイレクト、になる。

/example が load イベントを待っている間にトップにリダイレクトされるため、await page.goto('/example') が失敗する。

手動では再現できない。

トップにリダイレクトする処理があるのは、Auth.js のログイン時 redirectTo オプション指定と、middleware でログイン済みのユーザーがログインページに来た時。

warning 出てる。

 ⚠ Fast Refresh had to perform a full reload. Read more: https://nextjs.org/docs/messages/fast-refresh-reload

https://nextjs.org/docs/messages/fast-refresh-reload

The file you're editing might have other exports in addition to a React component.

編集中のファイルはない。watch されてる可能性はある。

Your React component is an anonymous function.

下みたいなこと?それはない

export default () => {
  return <div>Hello, world!</div>;
};

The component name is in camelCase and not PascalCase, for example textField instead of TextField.

これはなかった。

9sako69sako6

next-auth/reactsignIn, signOut を実行するとハードナビゲーションが起きる。

9sako69sako6

コードをみるに、Credentials Provider を使用していて、redirect オプションを false にしていればハードナビゲーションは起きない。

// TODO: Do not redirect for Credentials and Email providers by default in next major
if (redirect || !isSupportingReturn) {
  const url = data.url ?? callbackUrl
  window.location.href = url
  // If url contains a hash, the browser does not reload the page. We reload manually
  if (url.includes("#")) window.location.reload()
  return
}
9sako69sako6

この問題、Auth.js は関係ないような気がしてきた。

起きる現象:
npm run dev で初回立ち上げた際、await page.goto('/someware') した後に await page.goto('/other') すると、await page.goto('/other')load イベントが発火する前にリロードが起きて /someware に戻される。

ページをリダイレクト、リロードするような何かしらの処理は現在いれていない。

  • next@14.2.4
  • @playwright/test@1.45.1
9sako69sako6

npm run dev した初回だけこの問題が起きる。かつ、warning のこいつも初回だけ。

 ⚠ Fast Refresh had to perform a full reload. Read more: https://nextjs.org/docs/messages/fast-refresh-reload

もっと情報を整理して再現できる条件がわかったら issue にしたい。開発環境でだけ起きる問題であろうことと、初回起動時だけ(初回コンパイル時だけ?)起きると思われるのと、手動では再現しない。悲しいワークアラウンドを挟むしかないのか

  try {
    // FIXME: goto 先に遷移せず、直前のページにハードナビゲーションされる現象がたまに発生することの対策。手動では再現しなかった。
    await page.goto('/somewhere');
  } catch (e) {
    if (e instanceof Error) console.error(e.message);
  }

テストを流し続けている感じ、特定のいくつかのパスでだけ起こっている気がしなくもない。
共通点は page.tsx が Server Component であることか。
Fast Refresh had to perform a full reload. の原因がどこかにあるはず。

9sako69sako6

登録済みでないユーザーがログインを試みたときにエラーにしたいんだが、カスタムエラーを返せない。
next-auth/reactsignIn であれば戻り値があり、中身はこれ。
{error: 'Configuration', status: 200, ok: true, url: null}
エラーの内容によらずこれ。

Server Actions の方だとカスタムエラー throw できるかも。

9sako69sako6

wrangler pages dev で立ち上げている状態だと middleware のなかで req.authnull にしかならないし、callback の authorizedauthnull にしかならない。

middleware 関数を定義して、その中で auth を呼び出す作戦もダメ。

cookie のキーを明示的に指定して取り出すことはできる。

9sako69sako6

cookie のキー名を任意に設定できるので、middleware ではキーがあるかどうかを見るようにした。

このスクラップは4ヶ月前にクローズされました