Open8

Firebase Authentication+SvelteKitでSign in with Googleを実現する

なかやばしなかやばし

前提

以下のようなアプリケーションを作りたい

  • SvelteKit(フロントエンド) + Golang(バックエンド) + Cloudflare Pages(SvelteKitのデプロイ先)で構成予定
  • FirebaseはFirebase Authenticationのみ(アカウント登録、ログインのみ)を使う予定
    • その他の情報はGolang側、PostgreSQL?で管理する
  • SvelteKitとGolangとではREST APIで通信する
    • GolangからはSvelteKitから受け取るtokenをもとにFirebase Admin SDKからアカウント情報を引き出す?

筆者の知識

  • Firebase何もわからん。手探り状態。
  • SvelteKitやCloudflare Pagesは触ったことがあるが、そこまで深くはやってない。

このスクラップが取り扱うこと

  • 前提を踏まえてFirebase Authenticationでユーザの登録、ログインを最低限扱えるまで
    • 具体的には?
      • Golang、PostgreSQLやFirebase Admin SDKは登場しない予定
      • Cloudflare Pages固有の問題も出てこない(はず
      • ロクインや登録ページからログイン、登録をさせて、ログインしたユーザでないと見られないページにリダイレクトさせるまで?

モチベーション

このscrapを書くにいたった動機

  • Svelte + Firebaseを取り扱う記事が少ない
  • Firebaseのセキュリティ的に注意すべき点が解説されていることがあまり多くなさそう?
  • Firebase SDKのバージョンを意識しない記事が多い
    • 現行バージョン9の情報に辿りつきにくい
      • 日時指定検索を使え
なかやばしなかやばし

ライブラリのバーションについて:

あまり関わりのなさそうな部分はカットした。

$ cat package.json 
{
        ...
        "devDependencies": {
                "@sveltejs/adapter-auto": "^3.0.0",
                "@sveltejs/kit": "^2.0.0",
                "@sveltejs/vite-plugin-svelte": "^3.0.0",
                "@types/eslint": "8.56.0",
                "@typescript-eslint/eslint-plugin": "^6.0.0",
                "@typescript-eslint/parser": "^6.0.0",
                "eslint": "^8.56.0",
                "eslint-config-prettier": "^9.1.0",
                "eslint-plugin-svelte": "^2.35.1",
                "prettier": "^3.1.1",
                "prettier-plugin-svelte": "^3.1.2",
                "svelte": "^4.2.7",
                "svelte-check": "^3.6.0",
                "tslib": "^2.4.1",
                "typescript": "^5.0.0",
                "vite": "^5.0.3"
        },
        "type": "module",
        "dependencies": {
                ...
                "firebase": "^10.7.2",
        }
}
なかやばしなかやばし

FirebaseのOptionを環境変数に書いておき、読み出したい!

そもそもFirebaseのOptionって何?

FirebaseのSDKをinitializeする際に与えるパラメータ。
具体的にはapikeyappIdprojectIdとかのこと。
使うFirebaseの機能によって増減するらしいが、上の3つは必須項目らしい。
Firebaseのサイトでプロジェクトを作成すると出てくるので、そのまま使えばよさそう。

SvelteKitで環境変数を扱いたい!

https://kit.svelte.dev/docs/adapter-node#environment-variables

幾つかバリエーションがあるっぽい?

  • private: SvelteKitにおけるserver(今の場合はCloudflare Pages)のみに共有したいときに使う

    • clientであるbrowserには共有されない前提
  • public: SvelteKitにおけるclientにも共有される情報を入れる

  • static: buildの成果物であるコードに直書きされる

  • dynamic: staticに対して成果物には直書きされず、responseを返す際に都度埋め込まれる?

これらから$env/dynamic/private$env/dynamic/public$env/static/private$env/static/publicのバリエーションがあるっぽい

なかやばしなかやばし

FirebaseでFirebase App named '[DEFAULT]' already exists with different options or config (app/duplicate-app).と怒られた!

バージョンによって解決方法が違うらしい?ので微妙に詰まった。
initializeappみたいなやつを何度も動かすと怒られるらしい?

https://dabohaze.site/firebase-initialize-app-error/ によれば、以下のように初期化されたAppの数が0のとき(=getApps().lengthが0であるとき?)のみinitializeAppを呼んでやればいいらしい:

import { initializeApp, getApps, type FirebaseOptions } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig: FirebaseOptions = {
    apiKey: "hogehoge",
    authDomain: "hogehoge",
    projectId: "fugafuga",
    appId: "foobar",
    ...
}

let app;
if (!getApps().length) {
    app = initializeApp(firebaseConfig);
}
...
const auth = getAuth(app);

Firebase JS SDKくん、initializeAppの返り値を変数としてとっておかなくとも内部的に保持していそうな雰囲気が様々な記事(この記事もそうだけど)を読んでいて感じる。Black Box感があってややもにょる……。

なかやばしなかやばし

SigninwithRedirectが最近のブラウザだとうまく動かないらしい?

こいつに書いてありそう:
https://firebase.google.com/docs/auth/web/redirect-best-practices?hl=ja#signinwithpopup

サードパーティ Cookie をブロックするブラウザでリダイレクト ログインを使用する場合のベスト プラクティスについて説明します。本番環境のすべてのブラウザで signInWithRedirect() を意図したとおりに機能させるには、ここに記載されているオプションのいずれかに従う必要があります。

Cloudflare Pagesを使っているのでこのページにおける解決策からいちばん実装が楽なオプション2、signInWithRedirect()をやめ、 signInWithPopup()を使うことにした。

ログインページの実装はこんな感じ:

<script lang="ts">
	import { initializeApp, type FirebaseOptions, getApps } from 'firebase/app';
	import { env } from '$env/dynamic/public';
	import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
	import { authStore } from '$lib/store';
	import { goto } from '$app/navigation';

	const firebaseConfig: FirebaseOptions = {
		apiKey: env.PUBLIC_FIREBASE_API_KEY,
		authDomain: env.PUBLIC_FIREBASE_AUTHDOMAIN,
		projectId: env.PUBLIC_FIREBASE_PROJECTID,
		storageBucket: env.PUBLIC_FIREBASE_STORAGEBUCKET,
		messagingSenderId: env.PUBLIC_FIREBASE_MESSAGINGSENDERID,
		appId: env.PUBLIC_FIREBASE_APPID
	};

  let app;
  if (!getApps().length) {
	  app = initializeApp(firebaseConfig);
  }
	const auth = getAuth(app);
	const handleGoogleLogin = async () => {
		signInWithPopup(auth, new GoogleAuthProvider())
			.then((res) => {
				if (res === null) {
					throw new Error("res is null")
				}
				authStore.set({ loggedIn: true, user: res.user });
				goto('/');
			})
			.catch((e) => {
				console.error(e);
			});
	};
</script>

<div>
	<button type="button" on:click={handleGoogleLogin}> Sign In with Google </button>
</div>
なかやばしなかやばし

ページをリロードするとログイン情報が失われてしまい、ログインしていないことになってしまう問題に直面してしまった。
最初はFirebase由来のAuthorizationヘッダをサーバに送りつける方法のみで認証していたが、Cookieでも認証できるようにしようと思っていたのだが、色々調べてみたところ、このページを見て解決した。

https://zenn.dev/rabee/articles/firebase-auth-wait-for-initialization

どうやらFirebase Authenticationがページ内で初期化される前にユーザがログインしているかどうかを確認してしまっていたようだ。