Supabase Auth を Next.js の App router で使用する
使用したライブラリのバージョン
- next: 14.0.4
- @supabase/ssr: 0.0.10
- @supabase/supabase-js: 2.39.0
Supabase Auth
Quick Start
Next.js App Router で Supabase Auth を使用した場合のサンプルリポジトリ
Next.js で使用するライブラリは Supabase から2 つ提供されている
公式としては @supabase/ssr を推奨している
公式ドキュメントの中でも @supabase/auth-helpers-nextjs を使用した例が多いので注意する
サーバー側の言語やフレームワーク (Next.js、SvelteKit、Remix など) で Supabase を使用する場合、ユーザー セッションの保存に Cookie を使用するように Supabase クライアントを構成することが重要です。このプロセスをできるだけシンプルにするために、 @supabase/ssr パッケージを開発しました。このパッケージは現在 beta にあります。導入が推奨されますが、API はまだ不安定であり、将来的に重大な変更が加えられる可能性があることに注意してください。
Google 翻訳
npm install @supabase/ssr
@supabase/ssr を使用して Supabase クライアントを作成すると、Cookie を使用するように自動的に構成されます。これは、ユーザーのセッションが Next.js スタック全体 (クライアント、サーバー、App Router、Pages Router) で利用できることを意味します。
Google 翻訳
@supabase/auth-helpers-nextjs では 5 つほどのクライアントがあった
- createClientComponentClient: in Client Components
- createServerComponentClient: in Server Components
- createServerActionClient: in Server Actions
- createRouteHandlerClient: in Route Handlers
- createMiddlewareClient: in Middleware
@supabase/ssr では 2 つにまとまっていて見通しは良くなっている
- createBrowserClient: in Client Components
- createServerClient: in Server Components, Server Actions, Route Handlers, Middleware
Supabase Auth で認証すると主に以下の 2 つが発行される
- JWT 形式のアクセストークン
- リフレッシュトークン
アクセストークンは次のような形式
このアクセストークンはデモ用で公式ドキュメントに記載されているもの
※みやすさのために改行
eyJhbGciOiJIUzI1NiJ9
.eyJzdWIiOiIwMDAxIiwibmFtZSI6IlNhbSBWaW1lcyIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE4MjM5MDIyfQ
.zMcHjKlkGhuVsiPIkyAkB2rjXzyzJsMMgpvEGvGtjvA
前から header, payload, 署名に対応
署名は header, payload, 秘密鍵を header に指定されたハッシュ関数でハッシュ化したもの
アクセス トークンの有効期間は短く設計し、リフレッシュ トークンは期限切れになることはなく一度しか使用できないようにしている
リフレッシュトークンを交換して、新しいアクセス トークンとリフレッシュ トークンのペアを取得できる
ここで得たアクセストークンとクライアント側から Supabase API ゲートウェイをパスするために使用される JWT である anon key
と組み合わせて、例えば次のように Supabase の API にアクセスできる
curl 'https://${project_ref}.supabase.co/rest/v1/colors?select=name' \
-H "apikey: ${anone_key}" \
-H "Authorization: Bearer ${youur_access_token}"
このあたりの情報はここにまとまっていた
この動画もわかりやすかった
アクセストークンの Payload には例えば次のようにデータが格納されている
session_id などはサンプル
{
"aud": "authenticated",
"exp": 1615824388,
"sub": "0334744a-f2a2-4aba-8c8a-6e748f62a172",
"email": "d.l.solove@gmail.com",
"app_metadata": {
"provider": "email"
},
"user_metadata": null,
"role": "authenticated",
...
"session_id": "10b554fc-3cb8-4a15-a5ca-fcd23f7334e8"
}
Supabase の Database の中では auth
Schema で次のような関係で表現されていた
プロパティはだいぶ省略している
発行した JWT, リフレッシュトークンなどは次のような形式で、ブラウザ側では Cookie に保存している
Supabase クライアントを使用する際には、この Cookie と anon_key と合わせて送信するように構成している
{
"access_token": ${youur_access_token},
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1702738728,
"refresh_token": "wHOMKe8tQPAPloIr70rYpg",
...
}
単に session id をそのまま Cookie に持つことの違いは?
このアプローチには、JWT ベースのアプローチを使用する場合と比較して、いくつかのトレードオフがあります。
- 認証サーバーまたはそのデータベースがクラッシュしたり、数秒間でも利用できなくなったりすると、アプリケーション全体がダウンします。メンテナンスのスケジュール設定や一時的なエラーへの対処は非常に困難になります。
- 認証サーバーに障害が発生すると、他のシステムや API に一連の障害が発生し、アプリケーション システム全体が麻痺する可能性があります。
- 認証を必要とするすべてのリクエストは認証を介してルーティングされる必要があるため、すべてのリクエストに追加の待機時間のオーバーヘッドが追加されます。
google 翻訳
Cookie はデフォルトでは createBrowserClient, createServerClient に共通で次のようなオプションが渡されていた
const DEFAULT_COOKIE_OPTIONS = {
path: "/",
sameSite: "lax",
httpOnly: false,
maxAge: 60 * 60 * 24 * 365 * 1e3
};
公式では少なくとも HttpOnly
は true にする必要ないと公式が示している
Secure
は設定したい気持ちになる
Client の初期化時に以下のような設定を渡してあげる感じだろうか
{
secure: process.env.NODE_ENV === 'production',
...
}
通常、Cookie には Secure 属性が必要で、HTTPS 経由でのみ送信されます。ただし、localhost で開発する場合、これが問題になる可能性があります。
Google 翻訳
Quick Start ではこんな感じで Client Util を整備していた
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = (cookieStore: ReturnType<typeof cookies>) => {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options })
} catch (error) {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'
export const createClient = (request: NextRequest) => {
// Create an unmodified response
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
// If the cookie is updated, update the cookies for the request and response
request.cookies.set({
name,
value,
...options,
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({
name,
value,
...options,
})
},
remove(name: string, options: CookieOptions) {
// If the cookie is removed, update the cookies for the request and response
request.cookies.set({
name,
value: '',
...options,
})
response = NextResponse.next({
request: {
headers: request.headers,
},
})
response.cookies.set({
name,
value: '',
...options,
})
},
},
}
)
return { supabase, response }
}
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
Middleware は Server Component が読み込まれる前に、トークンのリフレッシュを行なえるように利用している
import { type NextRequest } from 'next/server'
import { createClient } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
const { supabase, response } = createClient(request)
// Refresh session if expired - required for Server Components
// https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-session-with-middleware
await supabase.auth.getSession()
return response
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
Supabase Auth では認可コードフローとして OAuth2.0 の拡張仕様である PKCE(Proof Key for Code Exchange) を含めた方法を推奨している
このあたりはいい記事がたくさんあるので、そちらを参考にする
現状 PKCE をサポートしている Supabase Auth のメソッドは以下
- signInWithOtp
- signInWithOAuth
- signUp
- resetPasswordForEmail
At present, PKCE is supported on the Magic Link, OAuth, Sign Up, and Password Recovery routes. These correspond to the signInWithOtp, signInWithOAuth, signUp, and resetPasswordForEmail
signUp
関数の実装は GoTrue のなかにあり
flowType === 'pkce'
で PKCE のフローの処理を追加している
Supabase Auth は GoTrue を使用して作らている
async signUp(credentials: SignUpWithPasswordCredentials): Promise<AuthResponse> {
try {
await this._removeSession()
let res: AuthResponse
if ('email' in credentials) {
const { email, password, options } = credentials
let codeChallenge: string | null = null
let codeChallengeMethod: string | null = null
if (this.flowType === 'pkce') {
const codeVerifier = generatePKCEVerifier()
await setItemAsync(this.storage, `${this.storageKey}-code-verifier`, codeVerifier)
codeChallenge = await generatePKCEChallenge(codeVerifier)
codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'
}
res = await _request(this.fetch, 'POST', `${this.url}/signup`, {
headers: this.headers,
redirectTo: options?.emailRedirectTo,
body: {
email,
password,
data: options?.data ?? {},
gotrue_meta_security: { captcha_token: options?.captchaToken },
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
xform: _sessionResponse,
})
} else if ('phone' in credentials) {
...
})
} else {
throw new AuthInvalidCredentialsError(
'You must provide either an email or phone number and a password'
)
}
const { data, error } = res
if (error || !data) {
return { data: { user: null, session: null }, error: error }
}
const session: Session | null = data.session
const user: User | null = data.user
if (data.session) {
await this._saveSession(data.session)
await this._notifyAllSubscribers('SIGNED_IN', session)
}
return { data: { user, session }, error: null }
} catch (error) {
if (isAuthError(error)) {
return { data: { user: null, session: null }, error }
}
throw error
}
}
Supabase の Architecture はこちら
OSS のライブラリを集めて、Supabase を形作っている
PKCE を含む認可コードフロー
こちらの良記事の図を貼っています。
signup メソッドの中で code_verifier, code_challenge を作成している
if (this.flowType === 'pkce') {
const codeVerifier = generatePKCEVerifier()
await setItemAsync(this.storage, `${this.storageKey}-code-verifier`, codeVerifier)
codeChallenge = await generatePKCEChallenge(codeVerifier)
codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'
}
supabase では auth Schema の flow_states テーブルで保存している
サインアップ時には callback の URL を渡していて、リダイレクトされる
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${requestUrl.origin}/auth/callback`,
},
})
callback にリダイレクトされたら クエリパラメータで渡される code を抽出して
必要な code_verifier などを添えてアクセストークンを要求するリクエストを出している
これで Supabase Auth 側で検証が OK であればアクセストークン等を提供する
export async function GET(request: Request) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the Auth Helpers package. It exchanges an auth code for the user's session.
// https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
if (code) {
const cookieStore = cookies()
const supabase = createClient(cookieStore)
await supabase.auth.exchangeCodeForSession(code)
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(requestUrl.origin)
}
exchangeCodeForSession
のなかでの該当箇所はこんな感じ
...
const [codeVerifier, redirectType] = (
(await getItemAsync(this.storage, `${this.storageKey}-code-verifier`)) as string
).split('/')
const { data, error } = await _request(
this.fetch,
'POST',
`${this.url}/token?grant_type=pkce`,
{
headers: this.headers,
body: {
auth_code: authCode,
code_verifier: codeVerifier,
},
xform: _sessionResponse,
}
)
...
PKCE は認可コードが横取りされてしまった場合でも、安全性を保つために導入されている
ざっくり、認可コードのリクエスト主と、アクセストークンのリクエスト主が同じであることを、リクエスト主しかしらない値で検証するというアイディア