From 9104ce9a2c692116ad78528b70543fae2c2d7398 Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Wed, 16 Jul 2025 16:17:10 +0700 Subject: [PATCH] refactor(auth): migrate to new Supabase JWT Signing keys (#303) * refactor(auth): replace Supabase `User` type with new `JWTUserData` type across the codebase - Replaced usage of Supabase's `User` type with the newly defined `JWTUserData` type for better type mapping and alignment with JWT claims. - Refactored session-related components and hooks (`useUser`, `requireUser`) to use the updated user structure. - Updated Supabase client keys to use `publicKey` instead of `anonKey`. - Adjusted multi-factor authentication logic and components to use `aal` and additional properties. - Applied consistent naming for Supabase secret key functions. - Incremented version to 2.12.0. - Introduced a new `deprecated` property in the `EnvVariableModel` type to handle deprecated environment variables. - Updated the `EnvList` component to display a warning badge for deprecated variables, including reason and alternative suggestions. - Enhanced filtering logic to allow users to toggle the visibility of deprecated variables. - Added new deprecated variables for Supabase keys with appropriate reasons and alternatives. - Added support for filtering deprecated environment variables in the `FilterSwitcher` component. - Updated the `Summary` component to display a badge for the count of deprecated variables. - Introduced a button to filter and display only deprecated variables. - Adjusted filtering logic to include deprecated variables in the overall state management. add BILLING_MODE configuration to environment variables - Introduced a new environment variable `BILLING_MODE` to configure billing options for the application. - The variable supports two values: `subscription` and `one-time`. - Marked as deprecated with a reason indicating that this configuration is no longer required, as billing mode is now automatically determined. - Added validation logic for the new variable to ensure correct value parsing. --- .../app-environment-variables-manager.tsx | 80 ++++++++++++++++++- .../app/variables/lib/env-variables-model.ts | 73 +++++++++++++++++ .../site-header-account-section.tsx | 28 ++----- .../(marketing)/_components/site-header.tsx | 5 +- apps/web/app/(marketing)/layout.tsx | 10 ++- apps/web/app/error.tsx | 5 +- apps/web/app/global-error.tsx | 5 +- .../team-account-layout-sidebar.tsx | 7 +- apps/web/app/not-found.tsx | 7 +- .../personal-account-dropdown-container.tsx | 5 +- .../components/personal-account-dropdown.tsx | 12 +-- .../email/update-email-form-container.tsx | 6 +- .../email/update-email-form.tsx | 10 +-- .../mfa/multi-factor-auth-setup-dialog.tsx | 1 + .../password/update-password-container.tsx | 4 +- .../password/update-password-form.tsx | 8 +- .../src/components/user-workspace-context.tsx | 5 +- .../team-account-workspace-context.tsx | 5 +- packages/next/src/actions/index.ts | 7 +- packages/next/src/routes/index.ts | 7 +- packages/supabase/package.json | 3 +- .../supabase/src/clients/browser-client.ts | 2 +- .../supabase/src/clients/middleware-client.ts | 2 +- .../src/clients/server-admin-client.ts | 7 +- .../supabase/src/clients/server-client.ts | 2 +- ...-service-role-key.ts => get-secret-key.ts} | 12 +-- .../supabase/src/get-supabase-client-keys.ts | 14 ++-- packages/supabase/src/hooks/use-user.ts | 15 ++-- packages/supabase/src/require-user.ts | 71 ++++++++++++---- packages/supabase/src/types.ts | 13 +++ 30 files changed, 313 insertions(+), 118 deletions(-) rename packages/supabase/src/{get-service-role-key.ts => get-secret-key.ts} (57%) create mode 100644 packages/supabase/src/types.ts diff --git a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx index 0d782d228..627642dd8 100644 --- a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx +++ b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx @@ -16,6 +16,7 @@ import { EyeOff, EyeOffIcon, InfoIcon, + TriangleAlertIcon, } from 'lucide-react'; import { Subject, debounceTime } from 'rxjs'; @@ -121,6 +122,7 @@ function EnvList({ appState }: { appState: AppEnvState }) { const showPrivateVars = searchParams.get('private') === 'true'; const showOverriddenVars = searchParams.get('overridden') === 'true'; const showInvalidVars = searchParams.get('invalid') === 'true'; + const showDeprecatedVars = searchParams.get('deprecated') === 'true'; const toggleShowValue = (key: string) => { setShowValues((prev) => ({ @@ -421,6 +423,35 @@ function EnvList({ appState }: { appState: AppEnvState }) { + + + {(deprecated) => ( + + Deprecated + + + + + + + +
+
This variable is deprecated
+
+ Reason: {deprecated.reason} +
+ {deprecated.alternative && ( +
+ Use instead: {deprecated.alternative} +
+ )} +
+
+
+
+
+ )} +
@@ -506,7 +537,8 @@ function EnvList({ appState }: { appState: AppEnvState }) { !showPublicVars && !showPrivateVars && !showInvalidVars && - !showOverriddenVars + !showOverriddenVars && + !showDeprecatedVars ) { return true; } @@ -539,6 +571,10 @@ function EnvList({ appState }: { appState: AppEnvState }) { return !varState.validation.success; } + if (showDeprecatedVars && isInSearch) { + return !!model?.deprecated; + } + return isInSearch; }; @@ -561,6 +597,7 @@ function EnvList({ appState }: { appState: AppEnvState }) { overridden: showOverriddenVars, private: showPrivateVars, invalid: showInvalidVars, + deprecated: showDeprecatedVars, }} /> @@ -640,6 +677,7 @@ function FilterSwitcher(props: { overridden: boolean; private: boolean; invalid: boolean; + deprecated: boolean; }; }) { const secretVars = props.filters.secret; @@ -647,6 +685,7 @@ function FilterSwitcher(props: { const overriddenVars = props.filters.overridden; const privateVars = props.filters.private; const invalidVars = props.filters.invalid; + const deprecatedVars = props.filters.deprecated; const handleFilterChange = useUpdateFilteredVariables(); @@ -658,6 +697,7 @@ function FilterSwitcher(props: { if (overriddenVars) filters.push('Overridden'); if (privateVars) filters.push('Private'); if (invalidVars) filters.push('Invalid'); + if (deprecatedVars) filters.push('Deprecated'); if (filters.length === 0) return 'Filter variables'; @@ -665,7 +705,7 @@ function FilterSwitcher(props: { }; const allSelected = - !secretVars && !publicVars && !overriddenVars && !invalidVars; + !secretVars && !publicVars && !overriddenVars && !invalidVars && !deprecatedVars; return ( @@ -731,6 +771,15 @@ function FilterSwitcher(props: { > Overridden + + { + handleFilterChange('deprecated', !deprecatedVars); + }} + > + Deprecated + ); @@ -746,6 +795,12 @@ function Summary({ appState }: { appState: AppEnvState }) { return !variable.validation.success; }); + // Find deprecated variables + const deprecatedVariables = varsArray.filter((variable) => { + const model = envVariables.find((env) => env.name === variable.key); + return !!model?.deprecated; + }); + const validVariables = varsArray.length - variablesWithErrors.length; return ( @@ -773,6 +828,15 @@ function Summary({ appState }: { appState: AppEnvState }) { {overridden.length} Overridden + + 0}> + 0 })} + > + {deprecatedVariables.length} Deprecated + +
@@ -787,6 +851,17 @@ function Summary({ appState }: { appState: AppEnvState }) { + 0}> + + + @@ -860,6 +935,7 @@ function useUpdateFilteredVariables() { searchParams.delete('overridden'); searchParams.delete('private'); searchParams.delete('invalid'); + searchParams.delete('deprecated'); }; if (reset) { diff --git a/apps/dev-tool/app/variables/lib/env-variables-model.ts b/apps/dev-tool/app/variables/lib/env-variables-model.ts index 40cdd1c85..3dd3f1048 100644 --- a/apps/dev-tool/app/variables/lib/env-variables-model.ts +++ b/apps/dev-tool/app/variables/lib/env-variables-model.ts @@ -21,6 +21,10 @@ export type EnvVariableModel = { values?: Values; category: string; required?: boolean; + deprecated?: { + reason: string; + alternative?: string; + }; validate?: ({ value, variables, @@ -409,6 +413,10 @@ export const envVariables: EnvVariableModel[] = [ category: 'Supabase', required: true, type: 'string', + deprecated: { + reason: 'Replaced by new JWT signing key system', + alternative: 'NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', + }, validate: ({ value }) => { return z .string() @@ -419,6 +427,24 @@ export const envVariables: EnvVariableModel[] = [ .safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_SUPABASE_PUBLIC_KEY', + description: 'Your Supabase public API key.', + category: 'Supabase', + required: false, + type: 'string', + hint: 'Falls back to NEXT_PUBLIC_SUPABASE_ANON_KEY if not provided', + validate: ({ value }) => { + return z + .string() + .min( + 1, + `The NEXT_PUBLIC_SUPABASE_PUBLIC_KEY variable must be at least 1 character`, + ) + .optional() + .safeParse(value); + }, + }, { name: 'SUPABASE_SERVICE_ROLE_KEY', description: 'Your Supabase service role key (keep this secret!).', @@ -426,6 +452,10 @@ export const envVariables: EnvVariableModel[] = [ secret: true, required: true, type: 'string', + deprecated: { + reason: 'Renamed for consistency with new JWT signing key system', + alternative: 'SUPABASE_SECRET_KEY', + }, validate: ({ value, variables }) => { return z .string() @@ -444,6 +474,34 @@ export const envVariables: EnvVariableModel[] = [ .safeParse(value); }, }, + { + name: 'SUPABASE_SECRET_KEY', + description: + 'Your Supabase secret key (preferred over SUPABASE_SERVICE_ROLE_KEY).', + category: 'Supabase', + secret: true, + required: false, + type: 'string', + hint: 'Falls back to SUPABASE_SERVICE_ROLE_KEY if not provided', + validate: ({ value, variables }) => { + return z + .string() + .min(1, `The SUPABASE_SECRET_KEY variable must be at least 1 character`) + .refine( + (value) => { + const anonKey = + variables['NEXT_PUBLIC_SUPABASE_ANON_KEY'] || + variables['NEXT_PUBLIC_SUPABASE_PUBLIC_KEY']; + return value !== anonKey; + }, + { + message: `The SUPABASE_SECRET_KEY variable must be different from public keys`, + }, + ) + .optional() + .safeParse(value); + }, + }, { name: 'SUPABASE_DB_WEBHOOK_SECRET', description: 'Secret key for Supabase webhook verification.', @@ -474,6 +532,21 @@ export const envVariables: EnvVariableModel[] = [ return z.enum(['stripe', 'lemon-squeezy']).optional().safeParse(value); }, }, + { + name: 'BILLING_MODE', + description: 'Billing mode configuration for the application.', + category: 'Billing', + required: false, + type: 'enum', + values: ['subscription', 'one-time'], + deprecated: { + reason: 'This configuration is no longer required and billing mode is now automatically determined', + alternative: undefined, + }, + validate: ({ value }) => { + return z.enum(['subscription', 'one-time']).optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', description: 'Your Stripe publishable key.', diff --git a/apps/web/app/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/(marketing)/_components/site-header-account-section.tsx index be9653f45..8e74d19b0 100644 --- a/apps/web/app/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/(marketing)/_components/site-header-account-section.tsx @@ -3,11 +3,9 @@ import dynamic from 'next/dynamic'; import Link from 'next/link'; -import { useQuery } from '@tanstack/react-query'; - import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; -import { useSupabase } from '@kit/supabase/hooks/use-supabase'; +import { JWTUserData } from '@kit/supabase/types'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; @@ -35,17 +33,20 @@ const features = { enableThemeToggle: featuresFlagConfig.enableThemeToggle, }; -export function SiteHeaderAccountSection() { - const session = useSession(); +export function SiteHeaderAccountSection({ + user, +}: { + user: JWTUserData | null; +}) { const signOut = useSignOut(); - if (session.data) { + if (user) { return ( signOut.mutateAsync()} /> ); @@ -85,16 +86,3 @@ function AuthButtons() {
); } - -function useSession() { - const client = useSupabase(); - - return useQuery({ - queryKey: ['session'], - queryFn: async () => { - const { data } = await client.auth.getSession(); - - return data.session; - }, - }); -} diff --git a/apps/web/app/(marketing)/_components/site-header.tsx b/apps/web/app/(marketing)/_components/site-header.tsx index 5f409312d..333c21f9c 100644 --- a/apps/web/app/(marketing)/_components/site-header.tsx +++ b/apps/web/app/(marketing)/_components/site-header.tsx @@ -1,3 +1,4 @@ +import { JWTUserData } from '@kit/supabase/types'; import { Header } from '@kit/ui/marketing'; import { AppLogo } from '~/components/app-logo'; @@ -5,12 +6,12 @@ import { AppLogo } from '~/components/app-logo'; import { SiteHeaderAccountSection } from './site-header-account-section'; import { SiteNavigation } from './site-navigation'; -export function SiteHeader() { +export function SiteHeader(props: { user?: JWTUserData | null }) { return (
} navigation={} - actions={} + actions={} /> ); } diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx index 44d32e084..0c2e5d282 100644 --- a/apps/web/app/(marketing)/layout.tsx +++ b/apps/web/app/(marketing)/layout.tsx @@ -1,11 +1,17 @@ +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + import { SiteFooter } from '~/(marketing)/_components/site-footer'; import { SiteHeader } from '~/(marketing)/_components/site-header'; import { withI18n } from '~/lib/i18n/with-i18n'; -function SiteLayout(props: React.PropsWithChildren) { +async function SiteLayout(props: React.PropsWithChildren) { + const client = getSupabaseServerClient(); + const user = await requireUser(client, { verifyMfa: false }); + return (
- + {props.children} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 840dae616..f64335eb6 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { ArrowLeft, MessageCircle } from 'lucide-react'; import { useCaptureException } from '@kit/monitoring/hooks'; +import { useUser } from '@kit/supabase/hooks/use-user'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; @@ -20,9 +21,11 @@ const ErrorPage = ({ }) => { useCaptureException(error); + const user = useUser(); + return (
- +
{ useCaptureException(error); + const user = useUser(); + return (
- +
{ }; const NotFoundPage = async () => { + const client = getSupabaseServerClient(); + const user = await requireUser(client, { verifyMfa: false }); + return (
- +
{ - const factors = user?.factors ?? []; const hasAdminRole = user?.app_metadata.role === 'super-admin'; - const hasTotpFactor = factors.some( - (factor) => factor.factor_type === 'totp' && factor.status === 'verified', - ); + const isAal2 = user?.aal === 'aal2'; - return hasAdminRole && hasTotpFactor; + return hasAdminRole && isAal2; }, [user]); return ( diff --git a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form-container.tsx b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form-container.tsx index 026fdb035..83ec9759c 100644 --- a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form-container.tsx @@ -12,9 +12,11 @@ export function UpdateEmailFormContainer(props: { callbackPath: string }) { return ; } - if (!user) { + if (!user || !user.email) { return null; } - return ; + return ( + + ); } diff --git a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx index 03120cb21..eae61b3d9 100644 --- a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx @@ -1,7 +1,5 @@ 'use client'; -import type { User } from '@supabase/supabase-js'; - import { zodResolver } from '@hookform/resolvers/zod'; import { CheckIcon } from '@radix-ui/react-icons'; import { useForm } from 'react-hook-form'; @@ -34,10 +32,10 @@ function createEmailResolver(currentEmail: string, errorMessage: string) { } export function UpdateEmailForm({ - user, + email, callbackPath, }: { - user: User; + email: string; callbackPath: string; }) { const { t } = useTranslation('account'); @@ -61,10 +59,8 @@ export function UpdateEmailForm({ }); }; - const currentEmail = user.email; - const form = useForm({ - resolver: createEmailResolver(currentEmail!, t('emailNotMatching')), + resolver: createEmailResolver(email, t('emailNotMatching')), defaultValues: { email: '', repeatEmail: '', diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx index 2e0287e62..21b602b51 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx @@ -413,6 +413,7 @@ function FactorNameForm( function QrImage({ src }: { src: string }) { return ( + // eslint-disable-next-line @next/next/no-img-element {'QR; + return ( + + ); } diff --git a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx index 5c88f8176..d6f22a823 100644 --- a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx @@ -2,8 +2,6 @@ import { useState } from 'react'; -import type { User } from '@supabase/supabase-js'; - import { zodResolver } from '@hookform/resolvers/zod'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Check } from 'lucide-react'; @@ -31,10 +29,10 @@ import { Trans } from '@kit/ui/trans'; import { PasswordUpdateSchema } from '../../../schema/update-password.schema'; export const UpdatePasswordForm = ({ - user, + email, callbackPath, }: { - user: User; + email: string; callbackPath: string; }) => { const { t } = useTranslation('account'); @@ -69,8 +67,6 @@ export const UpdatePasswordForm = ({ }: { newPassword: string; }) => { - const email = user.email; - // if the user does not have an email assigned, it's possible they // don't have an email/password factor linked, and the UI is out of sync if (!email) { diff --git a/packages/features/accounts/src/components/user-workspace-context.tsx b/packages/features/accounts/src/components/user-workspace-context.tsx index aced8d41c..5ba67709b 100644 --- a/packages/features/accounts/src/components/user-workspace-context.tsx +++ b/packages/features/accounts/src/components/user-workspace-context.tsx @@ -2,9 +2,8 @@ import { createContext } from 'react'; -import { User } from '@supabase/supabase-js'; - import { Tables } from '@kit/supabase/database'; +import { JWTUserData } from '@kit/supabase/types'; interface UserWorkspace { accounts: Array<{ @@ -20,7 +19,7 @@ interface UserWorkspace { subscription_status: Tables<'subscriptions'>['status'] | null; }; - user: User; + user: JWTUserData; } export const UserWorkspaceContext = createContext( diff --git a/packages/features/team-accounts/src/components/team-account-workspace-context.tsx b/packages/features/team-accounts/src/components/team-account-workspace-context.tsx index c3fa685d9..5209ec746 100644 --- a/packages/features/team-accounts/src/components/team-account-workspace-context.tsx +++ b/packages/features/team-accounts/src/components/team-account-workspace-context.tsx @@ -2,14 +2,13 @@ import { createContext } from 'react'; -import { User } from '@supabase/supabase-js'; - import { Database } from '@kit/supabase/database'; +import { JWTUserData } from '@kit/supabase/types'; interface AccountWorkspace { accounts: Database['public']['Views']['user_accounts']['Row'][]; account: Database['public']['Functions']['team_account_workspace']['Returns'][0]; - user: User; + user: JWTUserData; } export const TeamAccountWorkspaceContext = createContext( diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts index e98eabf22..afd837460 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -2,13 +2,12 @@ import 'server-only'; import { redirect } from 'next/navigation'; -import type { User } from '@supabase/supabase-js'; - import { ZodType, z } from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { JWTUserData } from '@kit/supabase/types'; import { zodParseFactory } from '../utils'; @@ -30,14 +29,14 @@ export function enhanceAction< >( fn: ( params: Config['schema'] extends ZodType ? z.infer : Args, - user: Config['auth'] extends false ? undefined : User, + user: Config['auth'] extends false ? undefined : JWTUserData, ) => Response | Promise, config: Config, ) { return async ( params: Config['schema'] extends ZodType ? z.infer : Args, ) => { - type UserParam = Config['auth'] extends false ? undefined : User; + type UserParam = Config['auth'] extends false ? undefined : JWTUserData; const requireAuth = config.auth ?? true; let user: UserParam = undefined as UserParam; diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts index eb27c2670..853808446 100644 --- a/packages/next/src/routes/index.ts +++ b/packages/next/src/routes/index.ts @@ -3,13 +3,12 @@ import 'server-only'; import { redirect } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; -import { User } from '@supabase/supabase-js'; - import { z } from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { JWTUserData } from '@kit/supabase/types'; import { zodParseFactory } from '../utils'; @@ -24,7 +23,7 @@ interface HandlerParams< RequireAuth extends boolean | undefined, > { request: NextRequest; - user: RequireAuth extends false ? undefined : User; + user: RequireAuth extends false ? undefined : JWTUserData; body: Schema extends z.ZodType ? z.infer : undefined; params: Record; } @@ -75,7 +74,7 @@ export const enhanceRouteHandler = < params: Promise>; }, ) { - type UserParam = Params['auth'] extends false ? undefined : User; + type UserParam = Params['auth'] extends false ? undefined : JWTUserData; let user: UserParam = undefined as UserParam; diff --git a/packages/supabase/package.json b/packages/supabase/package.json index ced16bf54..df01cc171 100644 --- a/packages/supabase/package.json +++ b/packages/supabase/package.json @@ -18,7 +18,8 @@ "./require-user": "./src/require-user.ts", "./hooks/*": "./src/hooks/*.ts", "./database": "./src/database.types.ts", - "./auth": "./src/auth.ts" + "./auth": "./src/auth.ts", + "./types": "./src/types.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", diff --git a/packages/supabase/src/clients/browser-client.ts b/packages/supabase/src/clients/browser-client.ts index 747945e38..7e1819f82 100644 --- a/packages/supabase/src/clients/browser-client.ts +++ b/packages/supabase/src/clients/browser-client.ts @@ -10,5 +10,5 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys'; export function getSupabaseBrowserClient() { const keys = getSupabaseClientKeys(); - return createBrowserClient(keys.url, keys.anonKey); + return createBrowserClient(keys.url, keys.publicKey); } diff --git a/packages/supabase/src/clients/middleware-client.ts b/packages/supabase/src/clients/middleware-client.ts index 608dc3b81..01c1e1f00 100644 --- a/packages/supabase/src/clients/middleware-client.ts +++ b/packages/supabase/src/clients/middleware-client.ts @@ -19,7 +19,7 @@ export function createMiddlewareClient( ) { const keys = getSupabaseClientKeys(); - return createServerClient(keys.url, keys.anonKey, { + return createServerClient(keys.url, keys.publicKey, { cookies: { getAll() { return request.cookies.getAll(); diff --git a/packages/supabase/src/clients/server-admin-client.ts b/packages/supabase/src/clients/server-admin-client.ts index cd4e56d55..8d73273d1 100644 --- a/packages/supabase/src/clients/server-admin-client.ts +++ b/packages/supabase/src/clients/server-admin-client.ts @@ -4,9 +4,9 @@ import { createClient } from '@supabase/supabase-js'; import { Database } from '../database.types'; import { - getServiceRoleKey, + getSupabaseSecretKey, warnServiceRoleKeyUsage, -} from '../get-service-role-key'; +} from '../get-secret-key'; import { getSupabaseClientKeys } from '../get-supabase-client-keys'; /** @@ -17,8 +17,9 @@ export function getSupabaseServerAdminClient() { warnServiceRoleKeyUsage(); const url = getSupabaseClientKeys().url; + const secretKey = getSupabaseSecretKey(); - return createClient(url, getServiceRoleKey(), { + return createClient(url, secretKey, { auth: { persistSession: false, detectSessionInUrl: false, diff --git a/packages/supabase/src/clients/server-client.ts b/packages/supabase/src/clients/server-client.ts index cd4c82cb3..434700059 100644 --- a/packages/supabase/src/clients/server-client.ts +++ b/packages/supabase/src/clients/server-client.ts @@ -14,7 +14,7 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys'; export function getSupabaseServerClient() { const keys = getSupabaseClientKeys(); - return createServerClient(keys.url, keys.anonKey, { + return createServerClient(keys.url, keys.publicKey, { cookies: { async getAll() { const cookieStore = await cookies(); diff --git a/packages/supabase/src/get-service-role-key.ts b/packages/supabase/src/get-secret-key.ts similarity index 57% rename from packages/supabase/src/get-service-role-key.ts rename to packages/supabase/src/get-secret-key.ts index 8ecc270d7..90848198b 100644 --- a/packages/supabase/src/get-service-role-key.ts +++ b/packages/supabase/src/get-secret-key.ts @@ -3,14 +3,14 @@ import 'server-only'; import { z } from 'zod'; const message = - 'Invalid Supabase Service Role Key. Please add the environment variable SUPABASE_SERVICE_ROLE_KEY.'; + 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY.'; /** - * @name getServiceRoleKey + * @name getSupabaseSecretKey * @description Get the Supabase Service Role Key. * ONLY USE IN SERVER-SIDE CODE. DO NOT EXPOSE THIS TO CLIENT-SIDE CODE. */ -export function getServiceRoleKey() { +export function getSupabaseSecretKey() { return z .string({ required_error: message, @@ -18,7 +18,9 @@ export function getServiceRoleKey() { .min(1, { message: message, }) - .parse(process.env.SUPABASE_SERVICE_ROLE_KEY); + .parse( + process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY, + ); } /** @@ -27,7 +29,7 @@ export function getServiceRoleKey() { export function warnServiceRoleKeyUsage() { if (process.env.NODE_ENV !== 'production') { console.warn( - `[Dev Only] This is a simple warning to let you know you are using the Supabase Service Role. Make sure it's the right call.`, + `[Dev Only] This is a simple warning to let you know you are using the Supabase Secret Key. This key bypasses RLS and should only be used in server-side code. Please make sure it's the intended usage.`, ); } } diff --git a/packages/supabase/src/get-supabase-client-keys.ts b/packages/supabase/src/get-supabase-client-keys.ts index 6d8a53129..23596b77f 100644 --- a/packages/supabase/src/get-supabase-client-keys.ts +++ b/packages/supabase/src/get-supabase-client-keys.ts @@ -10,15 +10,15 @@ export function getSupabaseClientKeys() { description: `This is the URL of your hosted Supabase instance. Please provide the variable NEXT_PUBLIC_SUPABASE_URL.`, required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, }), - anonKey: z - .string({ - description: `This is the anon key provided by Supabase. It is a public key used client-side. Please provide the variable NEXT_PUBLIC_SUPABASE_ANON_KEY.`, - required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_ANON_KEY`, - }) - .min(1), + publicKey: z.string({ + description: `This is the public key provided by Supabase. It is a public key used client-side. Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY.`, + required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, + }), }) .parse({ url: process.env.NEXT_PUBLIC_SUPABASE_URL, - anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + publicKey: + process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, }); } diff --git a/packages/supabase/src/hooks/use-user.ts b/packages/supabase/src/hooks/use-user.ts index afc7ea71d..8ee3d6168 100644 --- a/packages/supabase/src/hooks/use-user.ts +++ b/packages/supabase/src/hooks/use-user.ts @@ -1,27 +1,22 @@ -import type { User } from '@supabase/supabase-js'; - import { useQuery } from '@tanstack/react-query'; +import { requireUser } from '../require-user'; +import { JWTUserData } from '../types'; import { useSupabase } from './use-supabase'; const queryKey = ['supabase:user']; -export function useUser(initialData?: User | null) { +export function useUser(initialData?: JWTUserData | null) { const client = useSupabase(); const queryFn = async () => { - const response = await client.auth.getUser(); + const response = await requireUser(client); - // this is most likely a session error or the user is not logged in if (response.error) { return undefined; } - if (response.data?.user) { - return response.data.user; - } - - return Promise.reject(new Error('Unexpected result format')); + return response.data; }; return useQuery({ diff --git a/packages/supabase/src/require-user.ts b/packages/supabase/src/require-user.ts index edfa5eb86..2da976141 100644 --- a/packages/supabase/src/require-user.ts +++ b/packages/supabase/src/require-user.ts @@ -1,19 +1,45 @@ -import type { SupabaseClient, User } from '@supabase/supabase-js'; +import type { SupabaseClient } from '@supabase/supabase-js'; import { checkRequiresMultiFactorAuthentication } from './check-requires-mfa'; +import { JWTUserData } from './types'; const MULTI_FACTOR_AUTH_VERIFY_PATH = '/auth/verify'; const SIGN_IN_PATH = '/auth/sign-in'; +/** + * @name UserClaims + * @description The user claims returned from the Supabase auth API. + */ +type UserClaims = { + aud: string; + exp: number; + iat: number; + iss: string; + sub: string; + email: string; + phone: string; + app_metadata: Record; + user_metadata: Record; + role: string; + aal: `aal1` | `aal2`; + session_id: string; + is_anonymous: boolean; +}; + /** * @name requireUser * @description Require a session to be present in the request * @param client */ -export async function requireUser(client: SupabaseClient): Promise< +export async function requireUser( + client: SupabaseClient, + options?: { + verifyMfa?: boolean; + }, +): Promise< | { error: null; - data: User; + data: JWTUserData; } | ( | { @@ -28,9 +54,9 @@ export async function requireUser(client: SupabaseClient): Promise< } ) > { - const { data, error } = await client.auth.getUser(); + const { data, error } = await client.auth.getClaims(); - if (!data.user || error) { + if (!data?.claims || error) { return { data: null, error: new AuthenticationError(), @@ -38,21 +64,36 @@ export async function requireUser(client: SupabaseClient): Promise< }; } - const requiresMfa = await checkRequiresMultiFactorAuthentication(client); + const { verifyMfa = true } = options ?? {}; - // If the user requires multi-factor authentication, - // redirect them to the page where they can verify their identity. - if (requiresMfa) { - return { - data: null, - error: new MultiFactorAuthError(), - redirectTo: MULTI_FACTOR_AUTH_VERIFY_PATH, - }; + if (verifyMfa) { + const requiresMfa = await checkRequiresMultiFactorAuthentication(client); + + // If the user requires multi-factor authentication, + // redirect them to the page where they can verify their identity. + if (requiresMfa) { + return { + data: null, + error: new MultiFactorAuthError(), + redirectTo: MULTI_FACTOR_AUTH_VERIFY_PATH, + }; + } } + // the client doesn't type the claims, so we need to cast it to the User type + const user = data.claims as UserClaims; + return { error: null, - data: data.user, + data: { + is_anonymous: user.is_anonymous, + aal: user.aal, + email: user.email, + phone: user.phone, + app_metadata: user.app_metadata, + user_metadata: user.user_metadata, + id: user.sub, + }, }; } diff --git a/packages/supabase/src/types.ts b/packages/supabase/src/types.ts new file mode 100644 index 000000000..ec2072d26 --- /dev/null +++ b/packages/supabase/src/types.ts @@ -0,0 +1,13 @@ +/** + * @name JWTUserData + * @description The user data mapped from the JWT claims. + */ +export type JWTUserData = { + is_anonymous: boolean; + aal: `aal1` | `aal2`; + email: string; + phone: string; + app_metadata: Record; + user_metadata: Record; + id: string; +};