QLITRE DIALY

HonoXとsupabaseで簡易認証機能を実装する

2024年10月22日

最近HonoXで植物成長記録サイトを作っていて、認証機能をFront側に持たせる必要があった。

supabaseを使ってみたら想像していたよりもすっと認証機能が作れたのでまとめておく。

作成した認証周りの機能について

  • Emailとパスワードでのサインアップ
  • ログイン
  • セッション管理
  • ログアウト
  • パスワードリセット

よくある機能集。

※チュートリアル形式にしているが、最後にソースコードリンクあり。

supabaseの設定

まずはsupabase側の設定を行う。

https://supabase.com/

githubアカウントなどでサインアップをしたのちに、まずは適当なプロジェクトを作成する。

作成が完了したらサイドバーのProject Settingにアクセス。

APIタブをクリックしてPROJECT_URLAPI_KEYをメモっておく。

HonoXプロジェクトの編集

ここからHonoXでsupabaseの認証を作成していく。

まずはプロジェクトを作成し、必要なライブラリをインストールする。

npm create hono@latest
>>>
Need to install the following packages:
[email protected]
Ok to proceed? (y) y

> npx
> create-hono
create-hono version 0.14.2
? Target directory supabaseauthtest
? Which template do you want to use? x-basic
? Do you want to install project dependencies? yes
? Which package manager do you want to use? yarn
√ Cloning the template
√ Installing project dependencies
🎉 Copied project files
Get started with: cd supabaseauthtest

supabaseのライブラリとzod-validatorミドルウェアを導入する。

cd supabaseauthtest
yarn add @supabase/supabase-js @hono/zod-validator

次にルートディレクトリに.dev.varsを作成しPROJECT_URLAPI_KEYを保存する。

PROJECT_URL=https://yourprojecturl.supabase.co
API_KEY=yourapikey

環境変数にアクセスするためにapp/global.d.tsを編集する。

// app/global.d.ts
import { } from 'hono'

type Head = {
  title?: string
}

declare module 'hono' {
  interface Env {
    Variables: {}
    Bindings: {
      PROJECT_URL: string;
      API_KEY: string;
    }
  }
  interface ContextRenderer {
    (content: string | Promise<string>, head?: Head): Response | Promise<Response>
  }
}

最低限のスタイルをあてるためにsimple.cssを用いる。

// app/routes/_renderer.ts
import { Style } from 'hono/css'
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from 'honox/server'

export default jsxRenderer(({ children, title }) => {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <link rel="icon" href="/favicon.ico" />
        <Script src="/app/client.ts" async />
        {/*追加*/}
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple-v1.css"></link>
        <Style />
      </head>
      <body>{children}</body>
    </html>
  )
})

vite.config.tsを編集する。

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

export default defineConfig({
  plugins: [honox({ devServer: { adapter } }), build()],
  ssr: {
    external: ['@supabase/supabase-js'],
  },
})

サインアップ

ここから実際に画面を作っていく。

まずはサインアップ機能から。routesディレクトリにsignup.tsxを作成する。


// app/routes/signup.tsx
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { createClient } from "@supabase/supabase-js/dist/main/index.js";

const schema = z.object({
  email: z.string().min(3).includes('@'),
  password: z.string().min(8),
});


export default createRoute((c) => {
  return c.render(
    <div>
      <h1>SIGN UP</h1>
      <form action="/signup" method='post'>
        <div>
          <input type="email" name="email" placeholder='email' />
        </div>
        <div>
          <input type="password" name="password" placeholder='password' />
        </div>
        <button type='submit'>
          signup
        </button>
      </form>
    </div>
  )
})

export const POST = createRoute(
  zValidator('form', schema, (result, c) => {
    if (!result.success) {
      return c.redirect('/signup', 303)
    }
  }), async (c) => {

    const { email, password } = c.req.valid('form')
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    const { data, error } = await supabase.auth.signUp({
      email: email,
      password: password,
      options: {
        emailRedirectTo: 'http://localhost:5173/signup_complete'
      }
    })

    return c.redirect('/signup_confirm', 303)
  })

HonoXでは以下のようにするとルートに応じたPOSTリクエストを受け取れる。

export const POST = createRoute(...)

signupが無事に通ると、検証メールが飛んでくるので、そのことを知らせるsignup_confirm.tsx、検証メールのリダイレクト先であるsignup_complete.tsxをそれぞれ作成する。

app/routes/signup_confirm.tsx
import { createRoute } from 'honox/factory'


export default createRoute((c) => {
  return c.render(
    <div>
        <p>仮登録が完了しました。</p>
        <p>メールのリンクをクリックして本登録を完了してください</p>
    </div>
  )
})
// app/routes/signup_complete.tsx
import { createRoute } from 'honox/factory'


export default createRoute((c) => {
  return c.render(
    <div>
      <p>本登録が完了しました</p>
      <p>ログインをしてください。</p>
      <a href="/login">LOGIN</a>
    </div>
  )
})

ここまででyarn devして挙動を確認する。

Sign UPページ。

confirm signupページ。

supabaseからメールが届く。

メールのリンクをクリックすると本登録が完了していることが分かる。

ログイン

続いてログイン機能を作成する。

基本的にはSIGNUPと同じでlogin.tsxを作成する。

// app/routes/login.tsx
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { createClient } from "@supabase/supabase-js/dist/main/index.js";

const schema = z.object({
  email: z.string().min(3).includes('@'),
  password: z.string().min(8),
});


export default createRoute((c) => {
  return c.render(
    <div>
      <h1>LOGIN</h1>
      <form action="/login" method='post'>
        <div>
          <input type="email" name="email" placeholder='email' />
        </div>
        <div>
          <input type="password" name="password" placeholder='password' />
        </div>
        <button type='submit'>
          login
        </button>
      </form>
    </div>
  )
})

export const POST = createRoute(
  zValidator('form', schema, (result, c) => {
    if (!result.success) {
      return c.redirect('/login', 303)
    }
  }), async (c) => {

    const { email, password } = c.req.valid('form')
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    const { data, error } = await supabase.auth.signInWithPassword({
      email: email,
      password: password,
    })
    if (data.user) {
      return c.redirect('/auth/super_secret', 303)
    }
    // ログイン失敗
    return c.redirect('/login', 303)
  })

ほぼsignupのロジックと同じ。

成功したら認証ルートである/auth/super_secretにリダイレクトさせる。

/auth/super_secret.tsxは適当に以下のようにする。

// app/routes/auth/super_secret.tsx
import { createRoute } from 'honox/factory'


export default createRoute((c) => {
    return c.render(
        <div>
            <h1>You are authorized</h1>
        </div>
    )
})

画面でログインできることを確認する。

セッション管理

続いてユーザーがログインしているかどうか…のセッション管理を行う。

supabaseのloginリクエスト時にaccess_tokenの情報が返ってくる。

これをHonoのCookie管理機能で保存する方向で実装をしてみる。

まずはlogin.tsxを編集する。

// app/routes/login.tsx
import { createRoute } from 'honox/factory'
...
import { setCookie } from 'hono/cookie'; //追加
...
export const POST = createRoute(
  zValidator('form', schema, (result, c) => {
    if (!result.success) {
      return c.redirect('/login', 303)
    }
  }), async (c) => {

    const { email, password } = c.req.valid('form')
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    const { data, error } = await supabase.auth.signInWithPassword({
      email: email,
      password: password,
    })
    if (data.user) {
      // coookieにセット
      setCookie(c, 'supabase_token', data.session.access_token)
      return c.redirect('/auth/super_secret', 303)
    }
    // ログイン失敗
    return c.redirect('/login', 303)
  })

checkauth.tsでcookieに保存されたtoken情報を元にgetUserリクエストを行って検証する。

// app/checkauth.ts
import { getCookie } from "hono/cookie"
import { createClient } from "@supabase/supabase-js/dist/main/index.js";
import { Context } from "hono";


export const checkauth = async (c: Context)=> {
    const supabase = createClient(
        c.env.PROJECT_URL,
        c.env.API_KEY
    );
    const token = getCookie(c, 'supabase_token');
    if (!token) return false;

    const { data, error } = await supabase.auth.getUser(token);
    if (error) return false;

    return !!data.user;
}

続いて_middleware.tsを作成して以下のようにする。

// app/routes/_middleware.tsx
import { createRoute } from 'honox/factory'
import { createMiddleware } from 'hono/factory'
import { checkauth } from '../checkauth'

export const supabaseMiddleware = createMiddleware(async (c, next) => {
    if (c.req.path.startsWith('/auth')) {
        const f = await checkauth(c)
        if (f) {
            await next()
            return
        } else {
            return c.redirect('/', 303)
        }
    }
    await next()
})

export default createRoute(supabaseMiddleware)

先ほどのcheckauthを用いてauthで始まるルートのみ認証をチェックする。

ブラウザのsecretウィンドウなどを開いて/auth/super_secretにアクセスしてトップページにリダイレクトされることを確認する。

ログアウト

続いてログアウト機能を実装する。

場所はどこでもいいが、index.tsx内にリンクを貼ることにする。

セッションを監視しながら表示を切り分ける。

// app/routes/index.tsx
import { createRoute } from 'honox/factory'
import { checkauth } from '../checkauth';

export default createRoute(async (c) => {
  const isAuth = await checkauth(c)

  return c.render(
    <div>
      {isAuth && (
        <div>
          <p>
            <a href="/logout">LOGOUT</a>
          </p>
        </div>
      )}
      {!isAuth && (
        <div>
          <p>
            <a href="/login">LOGIN</a>
          </p>
          <p>
            <a href="/signup">SIGN UP</a>
          </p>
        </div>
      )}
    </div>
  )
})

ログイン状態の時はログアウトリンク、ログアウト状態の時はログイン、サインアップリンクを表示。

logout.tsはsetしたCookieをdeleteしてsupabase clientでもログアウト処理をする。

// app/routes/logout.ts
import { createRoute } from 'honox/factory'
import { createClient } from "@supabase/supabase-js/dist/main/index.js";
import { deleteCookie } from 'hono/cookie';

export default createRoute(async (c) => {
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    await supabase.auth.signOut()
    deleteCookie(c, 'supabase_token')
    return c.redirect('/')
})

パスワードリセット

続いてパスワードリセット。

大まかな流れとしては、パスワードリセット要求→リンクがメールでくる→リンク先で実際の変更処理、という風になる。

まずはpassword_reset_request.tsxを作成する。

メールアドレスを記入して、再設定メールを送付するイメージ。

// app/routes/password_reset_request.tsx
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { createClient } from "@supabase/supabase-js/dist/main/index.js";

const schema = z.object({
  email: z.string().min(3).includes('@'),
});

export default createRoute((c) => {
  return c.render(
    <div>
      <h1>PASSWORD RESET</h1>
      <form action="/password_reset_request" method='post'>
        <div>
          <input type="email" name="email" placeholder='email' />
        </div>
        <button type='submit'>
          SEND PASSWORD RESET MAIL
        </button>
      </form>
    </div>
  )
})

export const POST = createRoute(
  zValidator('form', schema, (result, c) => {
    if (!result.success) {
      return c.redirect('/password_reset_request', 303)
    }
  }), async (c) => {

    const { email } = c.req.valid('form')
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
      redirectTo: 'http://localhost:5173/password_reset',
    })
    return c.redirect('/password_reset_request_confirm', 303)
  })

redirectToには実際にパスワードを設定するURLを設定する。

成功したらpassword_reset_request_confirm.tsxに誘導してメールが来る旨をそれとなく伝える。

// app/routes/password_reset_request_confirm.tsx
import { createRoute } from 'honox/factory'


export default createRoute((c) => {
  return c.render(
    <div>
      <p>パスワードの変更を受け付けました。</p>
      <p>メールのリンクをクリックして設定してください</p>
    </div>
  )
})

続いて実際にパスワードリセットを行うpassword_reset.tsxの実装に入る。

リクエストメールを送ると、上のようなメールが届く。

Reset Passwordのリンクにはリクエスト時に設定したredirectToのURLに下のようなtoken情報が付与されている。

http://localhost:5173/password_reset#access_token=eyJhbGciOiJI.....W21Bf3GulwI&expires_at=1729577857&expires_in=3600&refresh_token=aHf2FYrgD5rX3orVk5jLgQ&token_type=bearer&type=recovery

ここから少しはまる。

まず、supabaseの公式APIリファレンスによると、new passwordの設定リクエストは以下のようになる。

const { data, error } = await supabase.auth.updateUser({
  password: new_password
})

なんとなくURL遷移をした際によしなにtokenが設定されて、リクエストを叩けばパスワード更新ができるかな、と思っていたらうまくいかない。debug出力をするとsessionが確立されていないようだった。

APIリファレンスを調べるとsetSessionというドンピシャなリクエストがある。

  const { data, error } = await supabase.auth.setSession({
    access_token,
    refresh_token
  })

URL遷移時に事前にセッションを確立させて、そのうえでupdateUserをする、というのが正しい流れのようだ。

access_tokenrefresh_tokenはまさにURLにパラメーターとして付与されているので、そこから持ってくればよさそう。

あとは実装するだけに思えるが…ここからも少しはまる。

というのもhonoでc.req.query('access_token')のように書けば簡単に値を取得できるかなと思ったがundefinedとなってしまう。

ChatGPTなどに聞いてみると、ハッシュタグに続くプロパティはサーバーサイドからはアクセスができず、クライアントサイドで処理をする必要があるとのことだった。

HonoXではクライアントサイドの処理ができる機能が用意されていて、そちらを使うことで解決ができるという風に思って実装をした。

HonoXではクライアントコンポーネントはislandsディレクトリに作るのがルール。

islands/PasswordResetForm.tsxを以下のように書く。

// app/islands/PasswordResetForm.tsx
import { useState, useEffect } from 'hono/jsx'


export const PasswordResetForm = () => {
    const [accesstoken, setAccessToken] = useState('')
    const [refreshtoken, setRefreshToken] = useState('')
    /*urlからaccess_tokenとrefresh_tokenをセット*/
    useEffect(() => {
        const h = window.location.hash
        const params = new URLSearchParams(h.substring(1));
        const a = params.get('access_token') || ''
        const r = params.get('refresh_token') || ''
        setAccessToken(a)
        setRefreshToken(r)
    }, [])

    return (
        <form action="/password_reset" method='post'>
            <div>
                <input type="password" name="newpassword" placeholder='password' />
            </div>
            <div>
                {/*formに混ぜてサーバーサイドに送信*/}
                <input type="text" hidden name='accesstoken' value={accesstoken} />
                <input type="text" hidden name='refreshtoken' value={refreshtoken} />
            </div>
            <button type='submit'>
                RESET PASSWORD
            </button>
        </form>
    )
}

クライアントコンポーネントが呼び出されたタイミングでuseEffectしてwindowオブジェクトにアクセスしてURLのフルパスを取得する。

そうして、 access_tokenとrefresh_tokenをセットする。

ここで、VueなどのEmit関数のように子コンポーネントから親コンポーネントに値を渡す方法が良く分からなかったので、Form情報に混ぜて一緒に送信、という風にして橋渡しを実現している。

ここまで来てpassword_reset.tsxを作成する。

// app/routes/password_reset.tsx
import { createRoute } from 'honox/factory'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { createClient } from "@supabase/supabase-js/dist/main/index.js";
import { PasswordResetForm } from '../islands/PasswordResetForm';

const schema = z.object({
  newpassword: z.string().min(8),
  accesstoken: z.string().min(1),
  refreshtoken: z.string().min(1),
});


export default createRoute((c) => {
  return c.render(
    <div>
      <h1>PASSWORD RESET</h1>
      <PasswordResetForm />
    </div>
  )
})

export const POST = createRoute(
  zValidator('form', schema, (result, c) => {
    if (!result.success) {
      return c.redirect('/password_reset', 303)
    }
  }), async (c) => {
    const { newpassword, accesstoken, refreshtoken } = c.req.valid('form')
    const supabase = createClient(c.env.PROJECT_URL, c.env.API_KEY)
    if (accesstoken && refreshtoken) {
      // セッション設定
      const { data, error } = await supabase.auth.setSession({
        access_token: accesstoken,
        refresh_token: refreshtoken
      });
      // 新しいパスワードをセット
      await supabase.auth.updateUser({ password: newpassword })
      return c.redirect('/password_reset_complete', 303)
    }
  })

zodスキーマにtoken情報を織り込み、先ほど作成したクライアントコンポーネントを呼び出す。

クライアントコンポーネントでformがsubmitされるとPOST以下の処理に流れる。そこでformから新パスワード、トークンを受け取る。

そうして、セッションを確立させたあとupdateUserをする、という処理になる。

最後にパスワードリセットが完了したことを知らせるページを作って終了。

// app/routes/password_reset_complete.tsx
import { createRoute } from 'honox/factory'


export default createRoute((c) => {
  return c.render(
    <div>
      <p>パスワードリセットが完了しました。</p>
      <p>ログインをしてください。</p>
      <a href="/login">LOGIN</a>
    </div>
  )
})

おわりに

以上、supabaseとHonoXでの基本的な認証機能の実装をしてみた。

感想としてはパスワードリセットのところでつまづいたものの、全体的にすんなりと実装ができた感じがする。

実装をする中でzValidator とCookie関連の機能に初めて触れられて勉強になった。

zValidatorはサーバー側で検証ができるので、肥大しがちなクライアント側のフォームの実装がより簡潔になって開発体験が良い、と感じた。

Cookieは今回はキーと値をセットしただけだったが、流石はHonoといったところでOptionなども豊富にあり、もう少しセキュアな実装も実現できそう。

https://hono.dev/docs/helpers/cookie

supabaseも初めて使ってみたけどAPIと管理画面がシンプルで良かった(最初Cognitoでやろうとしたものの挫折した)。

ソースコード

githubに公開してます。

https://github.com/qlitre/honox-supabaseauth-template