SvelteKitのHooksを使ってFirebase AuthとSSRする
Hooks とは
src/hooks.js
(src/hooks.ts
, src/hooks/index.js
, src/hooks/index.ts
も利用可能)からhandle
, handleError
, getSession
, externalFetch
の 4 つの関数をエクスポートして、主にプリレンダリング時の挙動を補うことができます。
それぞれの役割
handle
SvelteKit に対してリクエストが呼ばれるたびに実行されて、レスポンスを決定します。
公式のサンプルコード
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ request, resolve }) {
request.locals.user = await getUserInformation(request.headers.cookie);
const response = await resolve(request);
return {
...response,
headers: {
...response.headers,
'x-custom-header': 'potato'
}
};
}
handleError
レンダリング(SSR)の実行中にエラーが発生したときに呼ばれます。
公式のサンプルコード
/** @type {import('@sveltejs/kit').HandleError} */
export async function handleError({ error, request }) {
// example integration with https://sentry.io/
Sentry.captureException(error, { request });
}
getSession
クライアントサイドから取得可能な情報を設定します。
ここで返すようにした値は、ページの load
関数の引数LoadInput
の session
や、 $app/stores
モジュールの session
ストアから取得できます。
公式のサンプルコード
/** @type {import('@sveltejs/kit').GetSession} */
export function getSession(request) {
return request.locals.user
? {
user: {
// only include properties needed client-side —
// exclude anything else attached to the user
// like access tokens etc
name: request.locals.user.name,
email: request.locals.user.email,
avatar: request.locals.user.avatar
}
}
: {};
}
externalFetch
サーバ側でレンダリング時に実行されるリクエストを編集できます。
公式のサンプルコード
/** @type {import('@sveltejs/kit').ExternalFetch} */
export async function externalFetch(request) {
if (request.url.startsWith('https://api.yourapp.com/')) {
// clone the original request, but change the URL
request = new Request(
request.url.replace('https://api.yourapp.com/', 'http://localhost:9999/'),
request
);
}
return fetch(request);
}
今回の記事では handle
と getSession
を使います。
request.locals
先のサンプルコードで request.locals
というオブジェクトが登場しますが、Hooks を使って SSR する上でこのオブジェクトが非常に重要になります。
これは、リクエスト中に一貫したデータを保管して置くことができる任意の型を持ったオブジェクトです。
-
handle
内でユーザ情報をrequest.locals
に保管して、 -
getSession
から取得できるようにして、 - ユーザ情報をもとにレンダリングするといったことが可能になります。
今回やること
Firebase Authentication と Hooks を利用して認証付きの SSR をします。
準備
すでに SvelteKit プロジェクトが作成されている前提で進めます。
Firebase の初期化処理 (src/lib/firebase)
src/lib/firebase
配下で、Firebase の初期化処理を実装します。
Firebase(firebase, firebase-admin) を npm install してください。
import type { FirebaseOptions } from 'firebase/app';
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
const firebaseConfig: FirebaseOptions = { /* 略 */ };
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
import admin from 'firebase-admin';
const app = admin.initializeApp();
export const auth = app.auth();
Cookie でセッションを管理するための準備 (src/routes/api/session.ts)
src/routes/api/session.ts
に、セッションを管理するためのエンドポイントを生やしていきます。
cookie
を npm install してください。
セッションの登録
set-cookie
ヘッダーを設定することで cookie を用いてセッションを登録していきます。
import { auth } from '$lib/firebase/server';
type PostBody = { token: string };
export const post: RequestHandler<Locals, PostBody> = async ({ body }) => {
const expiresIn = 60 * 60 * 24 * 5 * 1000;
const idToken = body.token;
const sessionCookie = await auth.createSessionCookie(idToken, { expiresIn });
return {
headers: {
'set-cookie': cookie.serialize('__session', sessionCookie, {
httpOnly: true,
path: '/',
secure: true,
sameSite: 'lax',
maxAge: expiresIn / 1000
})
}
};
};
セッションの削除
set-cookie
ヘッダーにmaxAge=0
を設定することで、cookie を削除します。
export const del: RequestHandler<Locals> = async ({ headers }) => {
const cookies = cookie.parse(headers.cookie ?? '');
await auth
.verifySessionCookie(cookies['__session'] ?? '')
.then((token) => auth.revokeRefreshTokens(token.sub))
.catch(console.error);
return {
headers: {
'set-cookie': cookie.serialize('__session', 'revoked', {
maxAge: 0,
path: '/'
})
}
};
};
ログイン/ログアウト時の処理 (src/lib/auth.ts)
src/lib/auth.ts
にsignIn
/signOut
関数を定義していきます。
セッションを用いない場合、Firebase のauth
オブジェクトを用いるだけですが、
今回のような場合ログイン、ログアウト時は先のエンドポイントを利用してセッションを管理する必要があります。
先のエンドポイントを利用する関数を先に定義します。
import { base } from '$app/paths';
const registerSessionToken: (token: string) => Promise<void> = (token) =>
fetch(`${base}/api/session`, {
method: 'POST',
body: JSON.stringify({ token }),
headers: { 'content-type': 'application/json' }
}).then(() => undefined);
const unregisterSessionToken: () => Promise<void> = () =>
fetch(`${base}/api/session`, { method: 'DELETE' }).then(() => undefined);
次に、実際に利用するsignIn
/signOut
を定義します。
import { auth } from '$lib/firebase/client';
import {
GoogleAuthProvider,
onAuthStateChanged,
signInWithPopup
} from 'firebase/auth';
const authProvider = new GoogleAuthProvider();
export const signIn: () => Promise<void> = () =>
auth.currentUser !== null
? Promise.reject(new Error('Already signed in.'))
: signInWithPopup(auth, authProvider)
.then((result) => result?.user.getIdToken())
.then(registerSessionToken);
export const signOut: () => Promise<void> = () =>
unregisterSessionToken().then(() => auth.signOut());
ここまでで、ようやく cookie を用いてセッションの管理(ユーザの識別)ができる様になりました。
Hooks を使う
request.locals
の設計
Locals の型は任意(any)なので予め取り決めておきます。
export type Locals = {
user?: User;
} | undefined;
User
はsrc/lib/auth.ts
に定義しておきます。
export type User = {
id: string;
name: string;
};
handle
内で、cookie からユーザ情報を取得、Locals に設定する
import { auth } from '$lib/firebase/server';
import cookie from 'cookie';
const getUserFromSession: (session: string) => Promise<User> = async (
session
) => {
const decoded = await auth.verifySessionCookie(session);
const user = await auth.getUser(decoded.uid);
return {
id: user.uid,
name: user.displayName ?? ''
};
};
export const handle: Handle<Locals> = async ({ request, resolve }) => {
const cookies = cookie.parse(request.headers.cookie || '');
if (cookies['__session']) {
request.locals.user = await getUserFromSession(cookies['__session']).catch(
() => undefined
);
}
return resolve(request);
};
getSession
でクライアントからユーザ情報を取得できるようにする
export const getSession: GetSession<Locals, unknown, User | undefined> = (
request
) => request.locals?.user;
session
ストアをuser
ストアとする
実際に格納されているのはユーザ情報なのでリネームすると同時に、直接書き換えられないようReadable
型にします。(session はWritable
)
import { session } from '$app/stores';
export const user: Readable<User | undefined> = session;
session
ストアを更新する
ログイン/ログアウト時にハイドレーションの一環として、クライアントサイドではsession
を自動で更新するようにします。
import { onAuthStateChanged } from 'firebase/auth';
import { auth } from './firebase/client';
if (browser)
onAuthStateChanged(auth, (u) => {
console.log('auth state changed', u);
if (u === null) session.set(undefined);
else session.set({ id: u.uid, name: u.displayName ?? '' });
});
ユーザ情報をもとに UI を構築する
<script lang="ts">
import { signIn, signOut, user } from '$lib/auth';
</script>
<h1>Hello {$user?.name ?? 'World'}!</h1>
{#if $user === undefined}
<button on:click={signIn}>sign in</button>
{:else}
<button on:click={signOut}>sign out</button>
{/if}
完成
以上、Firebase Authentication を用いた認証情報に基づいた SSR 方法の紹介でした。
CSRF 対策が甘い点や、session cookie がサインイン状態と完全に同期が取れていない点など改善の余地がありますが、大まかな流れは以上になります。
- ソースコード全体: https://github.com/ssssota/sveltekit-hooks-demo
- 完成物: https://sveltekit-hooks-demo.web.app/
おわりに
Svelte の良さとして、ストアがあることが挙げられますが、SvelteKit もその特徴がよく活かされています。
最近は、React のフレームワーク Remix がリリースされ界隈を騒がせ?ましたが、SvelteKit も負けず劣らずの魅力があります。
(React と Svelte のフレームワークを比較しても仕方ないという話はありますが)
SvelteKit は Production Ready ではないので、2022 年での飛躍に期待しましょう。
Discussion