🔐

HonoX で Auth.js の CustomPage を使う

2024/12/24に公開

flore という Feature Flag SaaS(?になるかもわからない) を開発し始めました。
せっかくだし気になっていたHonoxをCloudflare Workersにデプロイして使ってみようと思ったので、認証周りをあまり考えたくない & 拡張性を維持したいので Auth.js にまかせようと思い、 @hono/auth-js を利用してみています。

そこで、Login画面などをカスタマイズ出来る Auth.js の Custom Signin を使おうとしたところ思ったよりはまったのでメモ的に記事に残しておきます
今回の記事では Auth.js が何か、Hono が何かについては一切書きませんのでご了承ください。

前提

今回は

  • honox v0.27.4
  • hono v4.6.14
  • @auth/core v0.37.4
  • @hono/auth-js v1.0.15
  • drizzle-orm v0.38.2
  • @auth/drizzle-adapter v1.7.4

を使用します。スタイルは hono/css ではなく tailwindcssを利用します。
また、デプロイ先はCloudflare Workersを利用します。nodejs_compat(v2)のみ有効で、DB用にD1とクライアントサイドのJS/CSSの提供のため、Workers Assetを利用しています。

initAuthConfig, authHandler については server.ts 内で以下のように記述しています。

server.ts
const app = createApp({
  init(app) {
    app
      .use(
        '*',
        initAuthConfig((c) => {
          return {
            secret: c.env.AUTH_SECRET,
            providers: [
              GitHub({
                clientId: c.env.GITHUB_CLIENT_ID,
                clientSecret: c.env.GITHUB_CLIENT_SECRET,
              }),
            ],
            adapter: DrizzleAdapter(drizzle(c.env.DB))
          }
        })
      )
      .use('/api/auth/*', authHandler());
  },
});

方法① Islandコンポーネントを使ってクライアントJSからSignInを発火する

最も直感的でわかりやすい方法はこの方法だと思います。
@hono/auth-js は React向けの実装が含まれていますので、それらを使って signInsignOut, useSession などを利用することも出来ます。

ただ、依存先にreact(not react-dom) があるため、そのままではHonoX上では利用できません。
hono/jsxからreact-renderに移行しても良いのですが、せっかくならそのまま利用したいのでreactの一部実装が行われているhono/jsx/domにaliasを張ることで利用できるようにします。
やることはシンプルで `vite.config.tsにresolve.aliasを記述するのみです。
(build.rollupOptionsはtailwind向けの設定です)

vite.config.ts
import build from '@hono/vite-build/cloudflare-workers';
import adapter from '@hono/vite-dev-server/cloudflare';
import honox from 'honox/vite';
import { defineConfig } from 'vite';

export default defineConfig(({ mode }) => {
  return {
    plugins: [honox({ devServer: { adapter } }), build()],
    resolve: {
      alias: {
        react: 'hono/jsx/dom',
      },
    },
    build: {
      rollupOptions: {
        input: mode === 'client' ? ['app/index.css'] : [],
      },
    },
  };
});

こうすることでIsland内にコンポーネントを用意することでCustom Signinページの提供なども行えるようになります

SigninButton.tsx
import { signIn } from '@hono/auth-js/react';

export const SignInButton = () => {
  return (
    <button type={'button'} onClick={() => signIn('github')}>
      Sign In
    </button>
  );
};

方法② Formを使ってサーバーサイドからSignInを発火する

next-auth v5や@auth/sveltekitなどで提供されているServer Actionを利用し、クライアントJSを用いないsignInの発火です。
next-auth@auth/sveltekit では AuthInstance から handle(api周りのhandler) と共に適切に記述することで ServerAction として動作する signInsignOut がExportされていますが、@hono/auth-js ではExportされていません。
また、なんとかサーバーサイドで無理矢理signInを利用しようとしても、Cloudflare WorkersはWorkers内部から自身のWorkersへのfetchリクエストを送ることが基本的には出来ないため、/api/auth/signin/api/auth/csrfなどを叩くことも叶いません。

そこで、@auth/sveltekitnext-auth での実装を参考に honox で利用できる signInsignOut を実装します。
honoxでは同じファイル内に複数のmethodを定義できるため、GET側でform(自身にPOSTするもの)を用意し、POST側のhandlerとしてActionを実行することで Next.js 等の ServerAction と似た利用ができるためこれを活用します。

@auth/sveltekitnext-auth での実装を参考に signIn を作成する

next-auth での実装
https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/lib/actions.ts

@auth/sveltekit での実装
https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-sveltekit/src/lib/actions.ts

これらを参考にsignInを実装していきます。

auth.ts
import { Auth, createActionURL, raw, setEnvDefaults, skipCSRFCheck } from '@auth/core';
import { ProviderType } from '@auth/core/providers';
import { AuthEnv } from '@hono/auth-js';
import {
  signIn as signInReactFunction,
  signOut as signOutReactFunction,
} from '@hono/auth-js/react';
import { MiddlewareHandler } from 'hono';
import { env } from 'hono/adapter';
import { setCookie } from 'hono/cookie';

type SignInParameters = Parameters<typeof signInReactFunction>;

export function signIn(authorizationParams?: SignInParameters[2]): MiddlewareHandler {
  return async (c) => {
    const config = c.get('authConfig');

    const formData = await c.req.formData();
    const formDataStringValues: { [key: string]: string | null } = {
      provider: null,
      redirectTo: null,
    };
    formData.forEach((value, key) => {
      if (typeof value === 'string') {
        formDataStringValues[key] = value;
      }
    });

    const { provider, redirectTo, ...rest } = formDataStringValues;
    const rawHeaders = c.req.raw.headers;

    const ctxEnv = env(c) as AuthEnv;
    setEnvDefaults(ctxEnv, config);

    const reqUrl = new URL(c.req.url);
    const protocol = reqUrl.protocol;

    const callbackUrl = redirectTo ?? c.req.header().Referer ?? '/';
    const signInURL = createActionURL('signin', protocol, rawHeaders, ctxEnv, config);

    if (!provider) {
      signInURL.searchParams.append('callbackUrl', callbackUrl);
      return c.redirect(signInURL.toString(), 302);
    }

    let url = `${signInURL.toString()}/${provider}?${new URLSearchParams(authorizationParams).toString()}`;
    let foundProvider: { id?: SignInParameters[0]; type?: ProviderType } = {};

    for (const providerConfig of config.providers) {
      const { options, ...defaults } =
        typeof providerConfig === 'function' ? providerConfig() : providerConfig;
      const id = (options?.id as string | undefined) ?? defaults.id;
      if (id === provider) {
        foundProvider = { id, type: (options?.type as ProviderType | undefined) ?? defaults.type };
        break;
      }
    }

    if (!foundProvider.id) {
      const url = `${signInURL.toString()}?${new URLSearchParams({ callbackUrl }).toString()}`;
      return c.redirect(url, 302);
    }

    if (foundProvider.type === 'credentials') {
      url = url.replace('signin', 'callback');
    }

    const body = new URLSearchParams({ ...rest, callbackUrl });
    const req = new Request(url, { method: 'POST', headers: rawHeaders, body });
    req.headers.set('Content-Type', 'application/x-www-form-urlencoded');
    const res = await Auth(req, { ...config, raw, skipCSRFCheck });

    for (const resCookie of res?.cookies ?? []) {
      setCookie(c, resCookie.name, resCookie.value, {
        ...resCookie.options,
        sameSite:
          // ref: https://github.com/nextauthjs/next-auth/blob/a150f1e842fe44c068a9761c1f6e6d543c0f9d69/packages/core/src/lib/vendored/cookie.ts#L341-L360
          // typeof string -> sameSite lowercase string value
          // typeof boolean -> true = 'Strict', false = Invalid
          typeof resCookie.options.sameSite === 'string'
            ? resCookie.options.sameSite
            : resCookie.options.sameSite
              ? 'Strict'
              : undefined,
      });
    }

    return c.redirect(res.redirect!, 302);
  };
}

type SignOutParametes = Parameters<typeof signOutReactFunction>;

export function signOut(): MiddlewareHandler {
  return async (c) => {
    const config = c.get('authConfig');

    const formData = await c.req.formData();
    const redirectTo = formData.get('redirectTo');
    if (redirectTo && typeof redirectTo !== 'string') {
      return c.text('Invalid request: redirectTo is not string', 400);
    }

    const rawHeaders = c.req.raw.headers;

    const ctxEnv = env(c) as AuthEnv;
    setEnvDefaults(ctxEnv, config);

    const reqUrl = new URL(c.req.url);
    const protocol = reqUrl.protocol;

    const callbackUrl = redirectTo ?? c.req.header().Referer ?? '/';
    const signOutUrl = createActionURL('signout', protocol, rawHeaders, ctxEnv, config);

    const body = new URLSearchParams({ callbackUrl });
    const req = new Request(signOutUrl, { method: 'POST', headers: rawHeaders, body });
    req.headers.set('Content-Type', 'application/x-www-form-urlencoded');
    const res = await Auth(req, { ...config, raw, skipCSRFCheck });

    for (const resCookie of res?.cookies ?? []) {
      setCookie(c, resCookie.name, resCookie.value, {
        ...resCookie.options,
        sameSite:
          // ref: https://github.com/nextauthjs/next-auth/blob/a150f1e842fe44c068a9761c1f6e6d543c0f9d69/packages/core/src/lib/vendored/cookie.ts#L341-L360
          // typeof string -> sameSite lowercase string value
          // typeof boolean -> true = 'Strict', false = Invalid
          typeof resCookie.options.sameSite === 'string'
            ? resCookie.options.sameSite
            : resCookie.options.sameSite
              ? 'Strict'
              : undefined,
      });
    }

    return c.redirect(res.redirect!, 302);
  };
}

routeで作成した者を利用する

これらを各Routeの定義から利用します。

SignInページ

signin.tsx
import { createRoute } from 'honox/factory';

import { signIn } from '../../utils/auth';

export const POST = createRoute(signIn());

export default createRoute(async (c) => {
  const user = c.get('authUser');
  if (user) {
    return c.redirect('/');
  }

  return c.render(
    <div>
      <form method="post">
        <input type="hidden" name="provider" value="github" />
        <input type="hidden" name="redirectTo" value="/test" />
        <button type="submit">Sign in with GitHub</button>
      </form>
    </div>
  );
});

SignOutページ

test.tsx
import { createRoute } from 'honox/factory';

import { signOut } from '../../utils/auth';

export const POST = createRoute(signOut());

export default createRoute((c) => {
  const auth = c.get('authUser');
  return c.render(
    <div>
      <h1 class={'text-red-400'}>Test, {JSON.stringify(auth.session.user)}!</h1>
      <form method={'post'}>
        <input type={'hidden'} name={'redirectTo'} value={'/signin'} />
        <button type={'submit'}>Sign out</button>
      </form>
    </div>
  );
});

CustomPageを設定する

server.tsで定義されている initAuthConfig 内に pages.signIn を設定することで /api/auth/signin/signin にリダイレクトするようになります(このときcallbackURLがqueryParameterについてくるのでこの部分も追加で実装が必要です)

server.ts
const app = createApp({
  init(app) {
    app
      .use(csrf())
      .use(
        '*',
        initAuthConfig((c) => {
          return {
            secret: c.env.AUTH_SECRET,
            providers: [
              GitHub({
                clientId: c.env.GITHUB_CLIENT_ID,
                clientSecret: c.env.GITHUB_CLIENT_SECRET,
              }),
            ],
            adapter: DrizzleAdapter(drizzle(c.env.DB), {
              usersTable: users,
              accountsTable: accounts,
              sessionsTable: sessions,
              verificationTokensTable: verificationTokens,
            }),
            pages: {
              signIn: '/signin',
              error: '/error',
            },
          };
        })
      )
      .use('/api/auth/*', authHandler());
  },
});

これでJavaScriptを無効にしているユーザーでもログイン・ログアウトが行えるようになりました。

おわりに

Auth.jsはHonoXと組み合わせてもislandコンポーネントを活用することでとても簡単に認証回りを実装できるとても便利なライブラリです。
今回の方法②はJSが無効なユーザーでもログインが出来るという副産物的な利点しか存在しないような気がしていますが、islandコンポーネントをさけたい場合などの実装の参考になればうれしいです。

今回の実装はこちらにありますのでGitHubで見たい方はご確認ください
https://github.com/Kartore/flore/commit/d4c5d482e9353ec460a91e9e92d8c281e62bd60c

Discussion