🔐

SvelteKitのHooksを使ってFirebase AuthとSSRする

2021/12/04に公開

Hooks とは

src/hooks.js(src/hooks.ts, src/hooks/index.js, src/hooks/index.ts も利用可能)からhandle, handleError, getSession, externalFetch の 4 つの関数をエクスポートして、主にプリレンダリング時の挙動を補うことができます。

それぞれの役割

handle

SvelteKit に対してリクエストが呼ばれるたびに実行されて、レスポンスを決定します。

公式のサンプルコード
hooks.js
/** @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)の実行中にエラーが発生したときに呼ばれます。

公式のサンプルコード
hooks.js
/** @type {import('@sveltejs/kit').HandleError} */
export async function handleError({ error, request }) {
	// example integration with https://sentry.io/
	Sentry.captureException(error, { request });
}

getSession

クライアントサイドから取得可能な情報を設定します。

ここで返すようにした値は、ページの load 関数の引数LoadInputsession や、 $app/stores モジュールの session ストアから取得できます。

公式のサンプルコード
hooks.js
/** @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

サーバ側でレンダリング時に実行されるリクエストを編集できます。

公式のサンプルコード
hooks.js
/** @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);
}

今回の記事では handlegetSession を使います。

request.locals

先のサンプルコードで request.locals というオブジェクトが登場しますが、Hooks を使って SSR する上でこのオブジェクトが非常に重要になります。

これは、リクエスト中に一貫したデータを保管して置くことができる任意の型を持ったオブジェクトです。

  1. handle 内でユーザ情報を request.locals に保管して、
  2. getSession から取得できるようにして、
  3. ユーザ情報をもとにレンダリングするといったことが可能になります。

今回やること

Firebase Authentication と Hooks を利用して認証付きの SSR をします。

準備

すでに SvelteKit プロジェクトが作成されている前提で進めます。

Firebase の初期化処理 (src/lib/firebase)

src/lib/firebase 配下で、Firebase の初期化処理を実装します。

Firebase(firebase, firebase-admin) を npm install してください。

src/lib/firebase/client.ts
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);
src/lib/firebase/server.ts
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 を用いてセッションを登録していきます。

src/routes/api/session.ts
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 を削除します。

src/routes/api/session.ts
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.tssignIn/signOut関数を定義していきます。

セッションを用いない場合、Firebase のauthオブジェクトを用いるだけですが、
今回のような場合ログイン、ログアウト時は先のエンドポイントを利用してセッションを管理する必要があります。

先のエンドポイントを利用する関数を先に定義します。

src/lib/auth.ts
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を定義します。

src/lib/auth.ts
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)なので予め取り決めておきます。

src/lib/locals.ts
export type Locals = {
	user?: User;
} | undefined;

Usersrc/lib/auth.tsに定義しておきます。

src/lib/auth.ts
export type User = {
	id: string;
	name: string;
};

handle内で、cookie からユーザ情報を取得、Locals に設定する

src/hooks/index.ts
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でクライアントからユーザ情報を取得できるようにする

src/hooks/index.ts
export const getSession: GetSession<Locals, unknown, User | undefined> = (
	request
) => request.locals?.user;

sessionストアをuserストアとする

実際に格納されているのはユーザ情報なのでリネームすると同時に、直接書き換えられないようReadable型にします。(session はWritable)

src/lib/auth.ts
import { session } from '$app/stores';
export const user: Readable<User | undefined> = session;

ログイン/ログアウト時にsessionストアを更新する

ハイドレーションの一環として、クライアントサイドではsessionを自動で更新するようにします。

src/lib/auth.ts
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 を構築する

src/route/index.svelte
<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 がサインイン状態と完全に同期が取れていない点など改善の余地がありますが、大まかな流れは以上になります。

おわりに

Svelte の良さとして、ストアがあることが挙げられますが、SvelteKit もその特徴がよく活かされています。

最近は、React のフレームワーク Remix がリリースされ界隈を騒がせ?ましたが、SvelteKit も負けず劣らずの魅力があります。
(React と Svelte のフレームワークを比較しても仕方ないという話はありますが)

SvelteKit は Production Ready ではないので、2022 年での飛躍に期待しましょう。

Discussion