supabase Auth を Fresh(Deno) で使う(クライアントサイドとサーバーサイド)

これまで supabase に独自に実装の Twitter 連携の結果取得したアカウントの情報を入れていわゆるログイン機能を作っていた。
が、supabase には認証システムが準備されていてそれを使う方が、素直であろうことは予想できる。

今回は、Fresh で supabase Auth をクライアントサイドとサーバーサイド(SSR)で試みてみます。

参考

準備

今回は、Github 連携をするので Github と supabase の設定をしておきます。

Github

  • https://github.com/settings/applications/new にアクセスし、新規の OAuth application を登録
    • Application name: なんでもよい
    • Homepage URL: なんでもよい(http://localhost とかでよい)
    • Application description: 空欄で OK
    • Authorization callback URL: https://hogehoge.supabase.co/auth/v1/callback を登録

=> Client ID と Client secrets を控えておく。

supabase

Redirect URL と書かれている箇所は、Github 側設定の Authorization callback URL へ書き込む内容になる。
なので、実際のところ Github と supabase の設定は順番ではなくて行ったり来たりすることになる。

実装 - クライアントサイド

まずは、クライアントだけで、supabase auth を使ってみます。
クライアントだけの動的な動きなので、 islands で実装します。

routes/index.tsx
1
2
3
4
5
6
7
8
9
import Auth from "../islands/Auth.tsx";

export default function Home() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<Auth />
</div>
);
}
islands/Auth.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useEffect, useState } from "preact/hooks";
import { createClient } from "https://esm.sh/@supabase/supabase-js";

export default function Auth() {
const [session, setSession] = useState();
const [user, setUser] = useState();

// https://app.supabase.com/project/hogehoge/settings/api を参照して設定
const supabase = createClient(
"https://hogehoge.supabase.co",
"[Project API keys(anon public)]"
);

async function doSignIn() {
await supabase.auth.signInWithOAuth({
provider: "github",
options: {
redirectTo: "http://localhost:8000/",
},
});
}
const handleAuth = async (e) => {
e.preventDefault();
await doSignIn();
};
useEffect(() => {
(async () => {
setSession(await supabase.auth.getSession());
setUser(await supabase.auth.getUser());
})();
}, []);

return (
<div class="flex flex-col">
<p class="flex font-bold text-xl">認証</p>
<div class="flex w-full">
<button
type="submit"
class="border-4 border-gray-400"
onClick={handleAuth}
>
登録
</button>
</div>
<div class="flex w-full">
Session: {!!session ? JSON.stringify(session) : ""}
</div>
<div class="flex w-full">User: {!!user ? JSON.stringify(user) : ""}</div>
</div>
);
}

ログインだけなら、実装これだけ。

起動するとこちら。

「登録」ボタンを押すと、github に飛ばされて、Session と User の情報が取得できるように。

本来表示すべきではないんでしょうが、確認用の表示としてセッションとユーザーの情報を表示できました。

ソースコードの以下の部分がポイントになります。

1
2
3
4
const supabase = createClient(
"https://hogehoge.supabase.co",
"[Project API keys(anon public)]"
);

[Project API keys(anon public)] を設定していますが、こういったキーはクライアント側のソースに載ってもいいものなのかというのが懸念されます。

こちらのキーを取得したところにある文言を読むと次の様に記載があります。

This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies.

あなたのテーブルの行レベルセキュリティを有効にし、ポリシーを設定しているとき、このキーはブラウザーで安全に使用できます。

なるほど、やっていません。なので、設定してみます。

Row Level Security の設定

参考: supabase DOCS - Row Level Security

以下の設定でテーブルを用意。

Authentication の Policies を参照。

作り方が 2 つあり、今回は For full customization を選択。

作ったポリシーは、以下の通りです。

items の参照は誰でも可
1
2
3
4
CREATE POLICY "Public items are viewable by everyone." ON "public"."items"
AS PERMISSIVE FOR SELECT
TO public
USING (true)
items への insert は認証済みだけ
1
2
3
4
5
CREATE POLICY "Public items are insert by authenticated." ON "public"."items"
AS PERMISSIVE FOR INSERT
TO authenticated

WITH CHECK (true)
items のアップデートは禁止
1
2
3
4
5
CREATE POLICY "Public items are update by everyone." ON "public"."items"
AS PERMISSIVE FOR UPDATE
TO public
USING (false)
WITH CHECK (false)
items の削除は禁止
1
2
3
4
CREATE POLICY "Public items are delete by everyone." ON "public"."items"
AS PERMISSIVE FOR DELETE
TO public
USING (false)

ソースコードを以下のように修正します。

islands/Auth.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import { useEffect, useState } from "preact/hooks";
import { createClient } from "https://esm.sh/@supabase/supabase-js";

export default function Auth() {
const [session, setSession] = useState();
const [user, setUser] = useState();
const [items, setItems] = useState([]);

// https://app.supabase.com/project/hogehoge/settings/api を参照して設定
const supabase = createClient(
"https://hogehoge.supabase.co",
"[Project API keys(anon public)]"
);

async function doSignIn() {
await supabase.auth.signInWithOAuth({
provider: "github",
options: {
redirectTo: "http://localhost:8000/",
},
});
}
const handleAuth = async (e) => {
e.preventDefault();
await doSignIn();
};
const handleInsert = async (e) => {
e.preventDefault();
await supabase.from("items").insert({
user_id: user.id,
name: crypto.randomUUID(),
});
const { data, error } = await supabase.from("items").select();
setItems(data);
};

const handleUpdate = async (e) => {
e.preventDefault();
// status: 204 が返ってくる
await supabase
.from("items")
.update({
name: `Update: ${crypto.randomUUID()}`,
})
.eq("id", items.slice(-1)[0].id);
const { data, error } = await supabase.from("items").select();
setItems(data);
};
const handleDelete = async (e) => {
e.preventDefault();

// status: 204 が返ってくる
await supabase.from("items").delete().eq("id", items.slice(-1)[0].id);
const { data, error } = await supabase.from("items").select();
setItems(data);
};

useEffect(() => {
(async () => {
setSession(await supabase.auth.getSession());
setUser((await supabase.auth.getUser()).data.user);
const { data, error } = await supabase.from("items").select();
setItems(data);
})();
}, []);

return (
<div class="flex flex-col">
<p class="flex font-bold text-xl">認証</p>
<div class="flex w-full">
<button
type="submit"
class="border-4 border-gray-400"
onClick={handleAuth}
>
登録
</button>
</div>
<div class="flex w-full">
<button
type="submit"
class="border-4 p-3 border-gray-400"
onClick={handleInsert}
>
追加
</button>
<button
type="submit"
class="border-4 p-3 border-gray-400"
onClick={handleUpdate}
>
更新
</button>
<button
type="submit"
class="border-4 p-3 border-gray-400"
onClick={handleDelete}
>
削除
</button>
</div>
<div class="flex w-full">
Items: {!!items ? JSON.stringify(items) : ""}
</div>
</div>
);
}

動かしてみると次の様になる。

update と delete は許可していないのでできない。
status: 204 が返ってきていました。

ここで、user_id を直接削除します。

この段階では、リロードしても全件返ってきますが、次の様にポリシーを書き換えます。

items の参照は誰でも可
1
2
3
4
CREATE POLICY "Public items are viewable by everyone." ON "public"."items"
AS PERMISSIVE FOR SELECT
TO public
USING (uid() = user_id)

この設定し、再度読み込みを行うと user_id を削除した item が取得できなくなります。

user_id を削除した id = 3 が取得されていません。

セッションはどこにあるのか

確かめてみると次の 2 箇所にトークンが保管されていました。

  • Local Storage
    • sb-hogehoge-auth-token
  • Cookies
    • sb-refresh-token
    • sb-access-token

Cookie は、そのドメインが hogehoge.supabase.co になっていたので、サーバーには到達しないので、サーバーサイドではトークンは使えません。

実装 - サーバーサイド(SSR を頑張って試みる)

参考: supabase DOCS - Server-Side Rendering

参考元の記述を見ると、トークンをクライアントサイドで Cookie に詰めて渡せ。
と、いうことになっている。
HttpOnly は不要です。ということが書かれている。

自分のアプリ(ここではhttp://localhost:8000) に返ってくるとき、リダイレクト URL は次の様になっていることが示されています。

https://yourapp.com/...#access_token=<...>&refresh_token=<...>&...

# 以降は、ブラウザからサーバーに送信されることはない情報であり、認証/資格情報がアクセスログとして残らないようにするアプローチだそう。
そういった方法もあるのかと納得もしますが、普段よく触るものだと OAuth を使うならクエリに乗ってくるよなというのが所感だったのでトライしてみた。

流れとしては、次の通り。

  • / にアクセス
  • / にあるログイン用のリンクを踏む
  • supabase と github をそれどれ遷移
  • /callback に戻ってくる
  • 受け取った #~?~ に書き換え
  • トークンをセッションに保存して、/sign_in にリダイレクト
  • /sign_in にアクセスされたとき、セッションに紐づいたアクセストークンを用いて supabase からデータを呼び出し。
  • データをも含めて SSR して返却

#? に書き換える

今回 supabase の機能として使いたかった setSession や setAuth がどうも上手く動いていないらしく、対応方法が示されていたので、こちらを踏まえて進めます。

Github - supabase/gotrue-js - Pull requests - feat: remove deprecated session methods

/ は、supabase の 認証機能へのリンクだけを提供。

routes/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default function Home() {
return (
<div class="p-4 mx-auto max-w-screen-md">
<p class="flex font-bold text-xl">認証</p>
<div class="flex w-full">
<a
href="https://hogrhoge.supabase.co/auth/v1/authorize?provider=github&redirect_to=http%3A%2F%2Flocalhost%3A8000%2Fcallback"
type="submit"
class="border-4 border-gray-400"
>
登録
</a>
</div>
</div>
);
}

/callback は、#~ を持っていた時の ?~ への書き換えと /sign_in へのリダイレクトを担当します。
?~ に書き換えたことで受け取るトークンは、aloedb を使用した簡易なセッションに保管します。

routes/callback.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { Database } from "https://deno.land/x/aloedb/mod.ts";
import { setCookie } from "std_cookie";

interface SessionRecord {
session_id: string;
refresh_token: string;
access_token: string;
expires_at: number;
}

export const handler = {
async GET(req: Request, ctx) {
const searchParams = new URL(req.url).searchParams;

if (
!searchParams.has("access_token") ||
!searchParams.has("refresh_token") ||
!searchParams.get("expires_in")
) {
return await ctx.render();
}

const db = new Database<SessionRecord>("./file.json");

const session_id = crypto.randomUUID();

await db.insertOne({
session_id,
refresh_token: searchParams.get("refresh_token"),
access_token: searchParams.get("access_token"),
expires_at: new Date().getTime() + Number(searchParams.get("expires_in")),
});

const responseHeaders = new Headers({
Location: `http://${new URL(req.url).host}/sign_in`,
});

setCookie(responseHeaders, {
name: "session",
value: session_id,
maxAge: 3600,
path: "/",
httpOnly: true,
secure: true,
});

const response = new Response("", {
status: 303,
headers: responseHeaders,
});

return response;
},
};

export default function Auth() {
return (
<script
dangerouslySetInnerHTML={{
__html: `
const hash = window.location.hash;
if(hash.length > 0 && hash.includes("#")) {
window.location.replace(window.location.href.replace('#','?'));
}
`,
}}
/>
);
}

/callback では、セッションからトークンを取得し、データベースからデータを取得しています。

routes/sign_in.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.0.0";
import { Database } from "https://deno.land/x/aloedb/mod.ts";
import { getCookies } from "std_cookie";

interface SessionRecord {
session_id: string;
refresh_token: string;
access_token: string;
expires_at: number;
}

export const handler = {
async GET(req: Request, ctx) {
const cookies = getCookies(req.headers);

const db = new Database<SessionRecord>("./file.json");

const { session_id, ...session } = await db.findOne({
session_id: cookies.session,
});

// https://github.com/supabase/gotrue-js/pull/340 を参照
function inMemoryStorageProvider() {
const items = new Map();
return {
getItem: (key: string) => items.get(key),
setItem: (key: string, value: string) => {
items.set(key, value);
},
removeItem: (key: string) => {
items.delete(key);
},
};
}

const storage = inMemoryStorageProvider();
storage.setItem(`sb-hogehoge-auth-token`, JSON.stringify(session));
const authOptions = { auth: { storage } };

const supabase = createClient(
"https://hogehoge.supabase.co",
"[Project API keys(anon public)]",
{
...authOptions,
}
);

const { data, error } = await supabase.from("items").select();

return await ctx.render({
items: data,
});
},
};

export default function Auth(props) {
return (
<div class="flex flex-col">
<div class="flex w-full">
Items: {!props?.data?.items ? "" : JSON.stringify(props.data.items)}
</div>
</div>
);
}

見ての通り、 islands は使われておらず、SSR だけで supabase にアクセスしています。
フロントエンドの js で、supabase との通信やり取りをしていないのがポイント。

もう 1 つ手があるとここで気が付いた

#~?~ に書き換えて対応をしましたが、URL にトークンなどが載ってくるのを避けたいなら他の手も有るとここで気が付いたのでもう1つ試します。
#~ で届いた情報を form に埋め込んで転送します。

routes/callback.tsx(formで受け付ける)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import { Database } from "https://deno.land/x/aloedb/mod.ts";
import { setCookie } from "std_cookie";

interface SessionRecord {
session_id: string;
refresh_token: string;
access_token: string;
expires_at: number;
}

export const handler = {
async POST(req: Request, ctx) {
const formData = await req.formData();

if (
!formData.has("access_token") ||
!formData.has("refresh_token") ||
!formData.get("expires_in")
) {
return new Error();
}

const db = new Database<SessionRecord>("./file.json");

const session_id = crypto.randomUUID();

await db.insertOne({
session_id,
refresh_token: formData.get("refresh_token"),
access_token: formData.get("access_token"),
expires_at: new Date().getTime() + Number(formData.get("expires_in")),
});

const responseHeaders = new Headers({
Location: `http://${new URL(req.url).host}/sign_in`,
});

setCookie(responseHeaders, {
name: "session",
value: session_id,
maxAge: 3600,
path: "/",
httpOnly: true,
secure: true,
});

const response = new Response("", {
status: 303,
headers: responseHeaders,
});

return response;
},
};

export default function Auth() {
return (
<>
<form method="post" id="post_form" action="/callback">
<input type="hidden" name="access_token" id="access_token" />
<input type="hidden" name="refresh_token" id="refresh_token" />
<input type="hidden" name="provider_token" id="provider_token" />
<input type="hidden" name="expires_in" id="expires_in" />
<input type="hidden" name="token_type" id="token_type" />
</form>
<script
dangerouslySetInnerHTML={{
__html: `
window.location.hash.replace('#','').split("&").forEach((p)=>{
const [key, value] = p.split('=')
document.getElementById(key).value = value
})
document.getElementById("post_form").submit()
`,
}}
/>
</>
);
}

この方法でもアクセスログにもトークンなど情報が残らずに受け取りができます。

anon キーが露出しないので、いささかこちらの方が好みなのとより安心も感じます。


Fresh と supabase を使って SSR ができるまでを追いかけました。
既に確認していますが、supabase Edge functions でより API として固めた呼び出し方もあります。
今回のような、fresh から直接 supabase を呼び出す方法もあるので、どのような使い分けをするのかが課題になるのを感じます。

では。