Closed13
Remixと@supabase/ssrでGoogle認証したい!RLSもしたい!

ブログに焼き増ししました
本編↓
まずは設定していく
下記URLを参考にSupabaseとGoogle Cloudの設定をした
- Supabase
- Supabase URL取得
- SUPABASE ANON KEY取得
- Google CloudのクライアントIDを設定
- Google Cloud
- OAuth同意画面
- クライアントID(認証済みリダイレクト先はSupabaseから取得)

Supabaseのクライアントを作成する
環境変数の設定忘れないうちにやる
dev.vars
SUPABASE_URL=my_url
SUPABASE_ANON_KEY=my_key
参考を参考にクライアント作る
supabase.server.ts
import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
import type { AppLoadContext } from "@remix-run/cloudflare";
export function createSupabaseServerClient(request: Request, c:AppLoadContext) {
const headers = new Headers();
const supabase = createServerClient(
c.cloudflare.env.SUPABASE_URL,
c.cloudflare.env.SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return parseCookieHeader(request.headers.get("Cookie") ?? "");
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
headers.append("Set-Cookie", serializeCookieHeader(name, value, options))
);
},
},
});
return supabase;
}
参考

Auth処理詰め合わせセットを作る
ログインとかログアウト処理まとめたやつ
auth.supabase.server.ts
import { json } from "@remix-run/cloudflare";
import { createSupabaseServerClient } from "~/supabase/supabase.server";
import type { AppLoadContext } from "@remix-run/cloudflare";
export const signInWithGoogle = async (
request: Request,
c: AppLoadContext,
successRedirectPath: string = "http://localhost:3000/auth/callback",
) => {
const supabase = createSupabaseServerClient(request, c);
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: successRedirectPath,
},
});
return json({
ok: !error && data ? true : false,
data: data,
error: error ? error.message : "An SignIn error occurred"
});
};
export const signOut = async (
request: Request,
c: AppLoadContext,
successRedirectPath: string = "/",
) => {
const supabase = createSupabaseServerClient(request, c);
const { error } = await supabase.auth.signOut();
return json({
ok: !error ? true : false,
data: { url: successRedirectPath },
error: error ? error.message : "An SignIn error occurred"
});
};
export const getUser = async (
request: Request,
c: AppLoadContext,
) => {
const supabase = createSupabaseServerClient(request, c);
const {
data: { session },
} = await supabase.auth.getSession();
return session?.user ?? null;
};
export const isUserLoggedIn = async (
request: Request,
c: AppLoadContext,
) => {
const supabase = createSupabaseServerClient(request, c);
const {
data: { user },
} = await supabase.auth.getUser();
return !!user;
};
参考

ログインページとCallback先を作る
ログイン
サインインボタンを押したらactionが実行されてGoogleのログイン画面が表示される(はず。)
login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/cloudflare";
import { redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { isUserLoggedIn, signInWithGoogle } from "~/supabase/auth.supabase.server";
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
// 認証済みならuserにリダイレクトする
if (await isUserLoggedIn(request, context)) {
return redirect("/user");
}
return null;
};
export const action = async ({ request, context }: ActionFunctionArgs) => {
const data = await signInWithGoogle(request, context);
const parsedData = await data.json();
// googleへのリダイレクトURLはdata.urlに格納されているみたい
// 自動でリダイレクトはしてくれないので、リダイレクトする
return redirect(parsedData.data.url!);
};
export default function SignIn() {
return (
<>
<Form method="post">
<button type="submit">Sign In</button>
</Form>
</>
);
}
Callback
Google認証後にこのページに遷移してセッションに変換する
auth.callback.tsx
import { redirect } from "@remix-run/cloudflare";
import type {LoaderFunctionArgs} from "@remix-run/cloudflare";
import { createSupabaseServerClient } from "~/supabase/supabase.server";
export async function loader({ request, context }: LoaderFunctionArgs) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next") ?? "/";
const headers = new Headers();
if (code) {
const supabase = createSupabaseServerClient(request, context);
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return redirect(next, { headers });
}
}
// return the user to an error page with instructions
return redirect("/", { headers });
};
参考

いざ、ログイン
めっちゃエラー出た。
Callback先で下記エラー
Unexpected Server Error
AuthApiError: invalid request: both auth code and code verifier should be non-empty
でもURLにコードあるよ。。
http://localhost:3000/auth/callback?code=hoge
わからん!!

ログインに向けて修正
原因
リダイレクト時にヘッダーを指定し忘れた。
何を見ても解決出来ず、あきらめてTwitter見てたら同じ現象になってる人がいて気が付いた。
圧倒的感謝
修正
supabase.server.tsでheadersも返すようにした
supabase.server.ts
import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
import type { AppLoadContext } from "@remix-run/cloudflare";
/**
* 参考:https://github.com/aburio/remix-supabase/blob/main/app/lib/supabase/supabase.server.ts
* 参考:https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=remix&queryGroups=package-manager&package-manager=pnpm&queryGroups=environment&environment=remix-loader
*/
export function createSupabaseServerClient(request: Request, c:AppLoadContext) {
const headers = new Headers();
const client = createServerClient(
c.cloudflare.env.SUPABASE_URL,
c.cloudflare.env.SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return parseCookieHeader(request.headers.get("Cookie") ?? "");
},
setAll(cookiesToSet) {
for (const cookie of cookiesToSet) {
const { name, value, options } = cookie;
headers.append(
"Set-Cookie",
serializeCookieHeader(name, value, options),
);
}
},
},
cookieOptions: {
httpOnly: true,
secure: true,
},
});
return {
client,
headers,
};
}
auth.supabase.server.tsでheadersを返すようにした
auth.supabase.server.ts
import { json } from "@remix-run/cloudflare";
import { createSupabaseServerClient } from "~/supabase/supabase.server";
import type { AppLoadContext } from "@remix-run/cloudflare";
export const signInWithGoogle = async (
request: Request,
c: AppLoadContext,
successRedirectPath: string = "http://localhost:3000/auth/callback",
) => {
const supabase = createSupabaseServerClient(request, c);
const { data, error } = await supabase.client.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: successRedirectPath,
},
});
return {
ok: !error && data ? true : false,
data: data,
error: error && !data ? error.message : "",
headers: supabase.headers,
};
};
export const signOut = async (
request: Request,
c: AppLoadContext,
successRedirectPath: string = "/",
) => {
const supabase = createSupabaseServerClient(request, c);
const { error } = await supabase.client.auth.signOut();
return json({
ok: !error ? true : false,
data: { url: successRedirectPath },
error: error ? error.message : "",
headers: supabase.headers,
});
};
export const getUser = async (
request: Request,
c: AppLoadContext,
) => {
const supabase = createSupabaseServerClient(request, c);
const {
data: { session },
} = await supabase.client.auth.getSession();
return session?.user ?? null;
};
export const isUserLoggedIn = async (
request: Request,
c: AppLoadContext,
) => {
const supabase = createSupabaseServerClient(request, c);
const {
data: { user },
} = await supabase.client.auth.getUser();
return !!user;
};
ログインのリダイレクトにheadersを指定した
login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/cloudflare";
import { redirect } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { isUserLoggedIn, signInWithGoogle } from "~/supabase/auth.supabase.server";
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
// 認証済みならuserにリダイレクトする
if (await isUserLoggedIn(request, context)) {
return redirect("/user");
}
return null;
};
export const action = async ({ request, context }: ActionFunctionArgs) => {
const data = await signInWithGoogle(request, context);
const parsedData = data;
// googleへのリダイレクトURLはdata.urlに格納されているみたい
// 自動でリダイレクトはしてくれないので、リダイレクトする
return redirect(parsedData.data.url!, { headers: data.headers });
};
export default function SignIn() {
return (
<>
<Form method="post">
<button type="submit">Sign In</button>
</Form>
</>
);
}

サインアウト
ログインで設定されたCookieも消えました。
user.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json, redirect } from "@remix-run/cloudflare";
import { Form, useLoaderData } from "@remix-run/react";
import { getUser, signOut } from "~/supabase/auth.supabase.server";
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
const user = await getUser(request, context);
return json(user);
};
export const action = async ({ request, context }: ActionFunctionArgs) => {
const data = await signOut(request, context);
return redirect(data.data.url, { headers: data.headers });
};
export default function User() {
const data = useLoaderData<typeof loader>();
return (
<>
{data?.aud}
<Form method="post">
<button type="submit">Sign Out</button>
</Form>
</>
);
}

ここまでの成果物

次の目標
APIにぶん投げたい。
参考

HonoでAPI生やす
Hono側は以下の通り作成
/authin
がミドルウェアなし、/authmiddleware/authin
がミドルウェアで使ったやつ
hono/index.ts
import { Hono } from "hono";
import type { Bindings } from "./bindings";
import { jwt, verify } from "hono/jwt";
import type { JwtVariables } from "hono/jwt";
type Variables = JwtVariables;
const app = new Hono<{
Bindings: Bindings,
Variables: Variables,
}>();
app.use(
"/authmiddleware/*",
async (c, next) => {
const middleware = jwt({
secret: c.env.SUPABASE_SECRET_KEY
});
return middleware(c, next);
}
);
const route = app
.get("/", (c) => c.json(c.env.GREETING))
.get("/authin", async (c) => {
const token = c.req.header("Authorization")?.split(" ")[1];
const decodedPayload = await verify(token!, c.env.SUPABASE_SECRET_KEY);
console.log(decodedPayload);
return c.json("hoge");
})
.get("/authmiddleware/authin", (c) => {
console.log(c.var.jwtPayload);
return c.json("foo");
});
/**
* Hono RRC type definition of API
*/
export type AppType = typeof route;
export default app;

RemixでAPI叩いてみる
まずはHonoのRPCクライアントを作ってみる。
その時にheadersも追加するようにする
rpc.ts
import type { AppType } from "@acme/api";
import type { AppLoadContext } from "@remix-run/cloudflare";
import { hc } from "hono/client";
export function createHC(
c:AppLoadContext,
token: string | undefined,
) {
const url = "http://127.0.0.1:3100/";
return hc<AppType>(url, token ? {
headers: {
Authorization: `Bearer ${token}`,
},
} : {});
}
Loader内でAPIを叩いてみる。
Hono側でJWTからユーザ情報みれた
user.tsx
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";
import { redirect } from "@remix-run/cloudflare";
import { Form, useLoaderData } from "@remix-run/react";
import { getSession, signOut } from "~/supabase/auth.supabase.server";
import { createHC } from "~/lib/rpc";
export const loader = async ({ request, context }: LoaderFunctionArgs) => {
// セッションを取得
const session = await getSession(request, context);
// セッションからアクセストークンを取得してHonoのRPCクライアント作成
const client = createHC(context, session?.access_token);
// RPCクライアント使ってAPI叩いてみる
const res = await client.authmiddleware.authin.$get();
// 結果確認
console.log(await res.json());
return null;
};

ここまでの成果物

RLS
先駆者がいた。ほぼこの記事の通りやったらできた。
というわけで、以下が出来るようになった。
- @supabase/ssrとRemixでGoogle認証
- Honoで生やしたAPI側でJWT検証
- SupabaseのPostgreSQLでRLS
このスクラップは6ヶ月前にクローズされました