Cloudflare Pages edge ランタイムの Web で Firebase Authentication の認証を Auth.js でする
構成
- 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 ランタイムでは動かないので別の方法を探す必要がある。
案
-
Auth.js で Firebase Admin SDK が必要なのはトークンの verify 兼ユーザー情報取得のためだけの認識なので、Cloud Functions を作って(もしくは、Google REST API を使って)、
fetch
で動くようにする。
Auth.js を試す
公式ドキュメントに沿って設定していく。
Auth.js v5 からは edge ランタイムに対応しているはず。
下記記事を参考にさせてもらいました。
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 と同じ現象に見舞われた。
- next@14.2.3
- next-auth@5.0.0-beta.19
- firebase@10.12.2
next-auth v4系を試してみる。
- next-auth@4.24.7
ユーザー情報の取得はこれでできそうだと思ったが、Emulator の場合はどうするんだ...?
const res = await fetch(
`https://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',
},
},
);
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'
}
]
}
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
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 版も貼る。
しかし、結局 v5 で実装しないと edge 対応できない。v4 の実装をもとに v5 での issue をどうにか解決したい。
Error: headers was called outside a request scope
デバッグしていた結果、next-auth/react
の signIn
を使うと問題ないが、v5 の auth.ts から import
した signIn
を使うと問題が起きることがわかった。next-auth/react
を使うことで問題が起こるかはわからない。
// ok
import { signIn } from 'next-auth/react';
// ng
import { signIn } from '@/auth';
この辺のせいでは?
あー、auth.ts の方は Server actions で signIn
を呼ぶ前提だった。
Migration ガイドに沿って v5 に上げる。
- next-auth@5.0.0-beta.19
Cloudflare Pages の環境変数に AUTH_TRUST_HOST
, AUTH_SECRET
をセットした。
デプロイしてログインできた。
__Secure-authjs.session-token
という cookie が付与されている。
Client Component で useSession
した結果も得られる。
時代は edge だね。
Firebase SDK はデフォルトでブラウザに認証情報を保存するが、それをやめる設定をしておく。
デフォルト状態だと明示的に Firebase SDK でログアウトしない限り認証状態が残り、Firebase SDK と Auth.js 両方でログアウト処理をしなければならず複雑になる。
const auth = getAuth();
auth.setPersistence(inMemoryPersistence);
状態はメモリにのみ保存され、ウィンドウまたはアクティビティが更新されるとクリアされることを示します。
(と思ったが)
Firebase Authentication 側でもログイン状態を保持しておかないと、Firestore へのリクエストに request.auth
が乗らなくなる。下記のようにセキュリティルールで認証ができなくなる。このデメリットを思うと、毎回 Firebase Auth & Auth.js 両方ログアウトする方がいい。
match /users/{userId} {
allow read: if request.auth.uid == userId;
}
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,
});
この問題になってる。手動リロードしないと useSession
の結果が更新されない。
やっとドキュメントを見つけた。
Server Component では auth.ts の signIn
, signOut
,
Client Component では next-auth/react の signIn
, signOut
を使うってことか。
後者をつかったら手動リロードしなくても認証状態が反映されるようになった。
テストでエラーになる。
- 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/...
moduleNameMapper
で解決した。
const NextAuthReact = () => ({
useSession: jest.fn(),
});
export default NextAuthReact;
moduleNameMapper: {
'@/(.*)$': '<rootDir>/src/$1',
'next-auth/react': '<rootDir>/jest/next-auth-react.ts'
},
ローカルではいらなかったが、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
auth.ts の NextAuth
初期化時に trustHost: true
オプションを渡すようにした。
次のエラー。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
オプションが必要だった。
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"]
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
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.
これはなかった。
next-auth/react
の signIn
, signOut
を実行するとハードナビゲーションが起きる。
コードをみるに、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
}
この問題、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
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.
の原因がどこかにあるはず。
登録済みでないユーザーがログインを試みたときにエラーにしたいんだが、カスタムエラーを返せない。
next-auth/react
の signIn
であれば戻り値があり、中身はこれ。
{error: 'Configuration', status: 200, ok: true, url: null}
エラーの内容によらずこれ。
Server Actions の方だとカスタムエラー throw できるかも。
middleware をこれに沿って設定した。auth.ts の実装から provider の部分を剥がし、auth.config.ts に切り出す。authorized
コールバックを定義する。
いや、middleware の中で req.auth
が一生true
にならない
Auth.js ドキュメント全部読んできます。
wrangler pages dev
で立ち上げている状態だと middleware のなかで req.auth
が null
にしかならないし、callback の authorized
も auth
が null
にしかならない。
middleware 関数を定義して、その中で auth を呼び出す作戦もダメ。
cookie のキーを明示的に指定して取り出すことはできる。
cookie のキー名を任意に設定できるので、middleware ではキーがあるかどうかを見るようにした。