Closed13

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

sori883sori883

ブログに焼き増ししました
https://sori883.dev/posts/remix_auth_with_supabasessr/

本編↓

まずは設定していく

下記URLを参考にSupabaseとGoogle Cloudの設定をした

  • Supabase
    • Supabase URL取得
    • SUPABASE ANON KEY取得
    • Google CloudのクライアントIDを設定
  • Google Cloud
    • OAuth同意画面
    • クライアントID(認証済みリダイレクト先はSupabaseから取得)

https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=environment&environment=server&queryGroups=framework&framework=remix#configuration

sori883sori883

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;
}

参考

https://github.com/aburio/remix-supabase

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

sori883sori883

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;
};

参考

https://github.com/aburio/remix-supabase

https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=framework&framework=remix#application-code

sori883sori883

ログインページと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 });
};

参考

https://supabase.com/docs/guides/auth/social-login/auth-google?queryGroups=framework&framework=remix&queryGroups=environment&environment=server#application-code

sori883sori883

いざ、ログイン

めっちゃエラー出た。

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

わからん!!

sori883sori883

ログインに向けて修正

原因

リダイレクト時にヘッダーを指定し忘れた。
何を見ても解決出来ず、あきらめて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>
    </>
  );
}
sori883sori883

サインアウト

ログインで設定された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>
    </>
  );
}
sori883sori883

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;
sori883sori883

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;
};

このスクラップは6ヶ月前にクローズされました