diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 479cbd3876..67b1b52a92 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -4787,6 +4787,7 @@ export type Database = { Args: { plain_key: string; stored_hash: string } Returns: boolean } + verify_email_otp_auth: { Args: never; Returns: boolean } verify_mfa: { Args: never; Returns: boolean } } Enums: { diff --git a/supabase/functions/_backend/private/verify_email_otp.ts b/supabase/functions/_backend/private/verify_email_otp.ts index faef0ec4ca..72f0902d05 100644 --- a/supabase/functions/_backend/private/verify_email_otp.ts +++ b/supabase/functions/_backend/private/verify_email_otp.ts @@ -4,7 +4,7 @@ import { createHono, getClaimsFromJWT, middlewareAuth, parseBody, quickError, si import { cloudlog } from '../utils/logging.ts' import { clearFailedAccountAuth, isAccountRateLimited, recordFailedAccountAuth } from '../utils/rate_limit.ts' import { buildRateLimitInfo } from '../utils/rateLimitInfo.ts' -import { emptySupabase, supabaseAdmin } from '../utils/supabase.ts' +import { emptySupabase, supabaseAdmin, supabaseClient } from '../utils/supabase.ts' import { version } from '../utils/version.ts' const bodySchema = type({ @@ -17,6 +17,15 @@ export const app = createHono('', version) app.use('/', useCors) +export async function verifyEmailOtpAuthSession(c: Parameters[0], accessToken: string) { + const { data, error } = await supabaseClient(c, `Bearer ${accessToken}`).rpc('verify_email_otp_auth') + + return { + verified: data === true, + error, + } +} + app.post('/', middlewareAuth, async (c) => { const rawBody = await parseBody<{ token?: string, token_hash?: string, type?: 'email' | 'magiclink' }>(c) const token = rawBody.token?.replaceAll(' ', '') ?? '' @@ -104,6 +113,25 @@ app.post('/', middlewareAuth, async (c) => { return quickError(500, 'no_user', 'No user associated with OTP') } + const emailOtpAuth = await verifyEmailOtpAuthSession(c, verifyData.session.access_token) + if (emailOtpAuth.error) { + cloudlog({ + requestId: c.get('requestId'), + context: 'verify_email_otp - OTP session auth method check errored', + error: emailOtpAuth.error.message, + }) + return quickError(500, 'otp_auth_check_failed', 'OTP session verification check failed') + } + + if (!emailOtpAuth.verified) { + await recordFailedAccountAuth(c, auth.userId) + cloudlog({ + requestId: c.get('requestId'), + context: 'verify_email_otp - OTP session auth method check failed', + }) + return quickError(401, 'invalid_otp_auth', 'OTP session verification failed') + } + await clearFailedAccountAuth(c, auth.userId) const otpVerifiedAt = new Date().toISOString() diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 479cbd3876..67b1b52a92 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -4787,6 +4787,7 @@ export type Database = { Args: { plain_key: string; stored_hash: string } Returns: boolean } + verify_email_otp_auth: { Args: never; Returns: boolean } verify_mfa: { Args: never; Returns: boolean } } Enums: { diff --git a/supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql b/supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql new file mode 100644 index 0000000000..1e6c892ed2 --- /dev/null +++ b/supabase/migrations/20260608114543_split_mfa_session_and_email_otp_checks.sql @@ -0,0 +1,64 @@ +-- Split MFA session assurance from email OTP first-factor checks. +-- `aal2` is the source of truth for completed MFA. Email OTP can be an `aal1` +-- login method, so it must not satisfy the MFA gate used by RLS/admin checks. + +CREATE OR REPLACE FUNCTION public.verify_mfa() +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT + array[(SELECT COALESCE(auth.jwt()->>'aal', 'aal1'))] <@ ( + SELECT + CASE + WHEN count(id) > 0 THEN array['aal2'] + ELSE array['aal1', 'aal2'] + END AS aal + FROM auth.mfa_factors + WHERE (SELECT auth.uid()) = user_id + AND status = 'verified' + ); +$$; + +COMMENT ON FUNCTION public.verify_mfa() IS +'Returns true when the current session satisfies Supabase MFA assurance. Users with verified MFA factors require aal2; users without verified factors may use aal1 or aal2.'; + +ALTER FUNCTION public.verify_mfa() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.verify_mfa() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO anon; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO authenticated; +GRANT EXECUTE ON FUNCTION public.verify_mfa() TO service_role; + +CREATE OR REPLACE FUNCTION public.verify_email_otp_auth() +RETURNS boolean +LANGUAGE sql +STABLE +SET search_path = '' +AS $$ + WITH jwt_claims AS ( + SELECT auth.jwt() AS claims + ), + amr AS ( + SELECT + CASE + WHEN pg_catalog.jsonb_typeof(claims->'amr') = 'array' THEN claims->'amr' + ELSE '[]'::jsonb + END AS entries + FROM jwt_claims + ) + SELECT EXISTS ( + SELECT 1 + FROM amr, pg_catalog.jsonb_array_elements(amr.entries) AS amr_elem + WHERE amr_elem->>'method' = 'otp' + ); +$$; + +COMMENT ON FUNCTION public.verify_email_otp_auth() IS +'Returns true when the current JWT authentication-method reference includes OTP. This is first-factor/email OTP evidence and must not be used as MFA assurance.'; + +ALTER FUNCTION public.verify_email_otp_auth() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.verify_email_otp_auth() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.verify_email_otp_auth() TO authenticated; +GRANT EXECUTE ON FUNCTION public.verify_email_otp_auth() TO service_role; diff --git a/supabase/tests/58_test_mfa_session_otp_split.sql b/supabase/tests/58_test_mfa_session_otp_split.sql new file mode 100644 index 0000000000..ee54bcf073 --- /dev/null +++ b/supabase/tests/58_test_mfa_session_otp_split.sql @@ -0,0 +1,118 @@ +BEGIN; + +SELECT plan(6); + +SELECT tests.create_supabase_user( + 'mfa_session_split_with_mfa', + 'mfa-session-split-with-mfa@test.local' +); +SELECT tests.create_supabase_user( + 'mfa_session_split_without_mfa', + 'mfa-session-split-without-mfa@test.local' +); +SELECT tests.mark_email_otp_verified('mfa_session_split_with_mfa'); + +INSERT INTO auth.mfa_factors ( + id, + user_id, + friendly_name, + factor_type, + status, + created_at, + updated_at +) +VALUES ( + gen_random_uuid(), + tests.get_supabase_uid('mfa_session_split_with_mfa'), + 'Test TOTP', + 'totp'::auth.factor_type, + 'verified'::auth.factor_status, + NOW(), + NOW() +); + +SELECT tests.authenticate_as('mfa_session_split_without_mfa'); +SELECT set_config( + 'request.jwt.claims', + jsonb_build_object( + 'sub', tests.get_supabase_uid('mfa_session_split_without_mfa'), + 'email', 'mfa-session-split-without-mfa@test.local', + 'aal', 'aal1', + 'amr', '[]'::jsonb + )::text, + true +); +SELECT is( + public.verify_mfa(), + true, + 'verify_mfa allows aal1 when the user has no verified MFA factor' +); +SELECT tests.clear_authentication(); + +SELECT tests.authenticate_as('mfa_session_split_with_mfa'); +SELECT set_config( + 'request.jwt.claims', + jsonb_build_object( + 'sub', tests.get_supabase_uid('mfa_session_split_with_mfa'), + 'email', 'mfa-session-split-with-mfa@test.local', + 'aal', 'aal1', + 'amr', jsonb_build_array(jsonb_build_object('method', 'password')) + )::text, + true +); +SELECT is( + public.verify_mfa(), + false, + 'verify_mfa rejects aal1 when the user has a verified MFA factor' +); +SELECT is( + public.verify_email_otp_auth(), + false, + 'verify_email_otp_auth rejects non-OTP amr methods' +); +SELECT tests.clear_authentication(); + +SELECT tests.authenticate_as('mfa_session_split_with_mfa'); +SELECT set_config( + 'request.jwt.claims', + jsonb_build_object( + 'sub', tests.get_supabase_uid('mfa_session_split_with_mfa'), + 'email', 'mfa-session-split-with-mfa@test.local', + 'aal', 'aal1', + 'amr', jsonb_build_array(jsonb_build_object('method', 'otp')) + )::text, + true +); +SELECT is( + public.verify_mfa(), + false, + 'verify_mfa rejects aal1 OTP first-factor sessions when the user has MFA' +); +SELECT is( + public.verify_email_otp_auth(), + true, + 'verify_email_otp_auth recognizes OTP first-factor sessions separately' +); +SELECT tests.clear_authentication(); + +SELECT tests.authenticate_as('mfa_session_split_with_mfa'); +SELECT set_config( + 'request.jwt.claims', + jsonb_build_object( + 'sub', tests.get_supabase_uid('mfa_session_split_with_mfa'), + 'email', 'mfa-session-split-with-mfa@test.local', + 'aal', 'aal2', + 'amr', jsonb_build_array(jsonb_build_object('method', 'otp')) + )::text, + true +); +SELECT is( + public.verify_mfa(), + true, + 'verify_mfa allows aal2 when the user has a verified MFA factor' +); +SELECT tests.clear_authentication(); + +SELECT * FROM finish(); + +ROLLBACK; diff --git a/tests/security-definer-execute-hardening.test.ts b/tests/security-definer-execute-hardening.test.ts index 6ff11087be..5958791775 100644 --- a/tests/security-definer-execute-hardening.test.ts +++ b/tests/security-definer-execute-hardening.test.ts @@ -94,6 +94,7 @@ const AUTHENTICATED_ONLY_PROCS = [ 'public.update_org_invite_role_rbac(uuid, uuid, text)', 'public.update_org_member_role(uuid, uuid, text)', 'public.update_tmp_invite_role_rbac(uuid, text, text)', + 'public.verify_email_otp_auth()', ] as const describe('security definer execute hardening', () => { diff --git a/tests/verify-email-otp-auth-session.unit.test.ts b/tests/verify-email-otp-auth-session.unit.test.ts new file mode 100644 index 0000000000..f6375cccc7 --- /dev/null +++ b/tests/verify-email-otp-auth-session.unit.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + rpc: vi.fn(), + supabaseClient: vi.fn(), +})) + +vi.mock('../supabase/functions/_backend/utils/supabase.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + supabaseClient: mocks.supabaseClient, + } +}) + +const { verifyEmailOtpAuthSession } = await import('../supabase/functions/_backend/private/verify_email_otp.ts') + +describe('verifyEmailOtpAuthSession', () => { + beforeEach(() => { + mocks.rpc.mockReset() + mocks.supabaseClient.mockReset() + mocks.supabaseClient.mockReturnValue({ rpc: mocks.rpc }) + }) + + it('checks the OTP access token with verify_email_otp_auth', async () => { + const context = {} as Parameters[0] + mocks.rpc.mockResolvedValue({ data: true, error: null }) + + await expect(verifyEmailOtpAuthSession(context, 'otp-access-token')).resolves.toEqual({ + verified: true, + error: null, + }) + + expect(mocks.supabaseClient).toHaveBeenCalledWith(context, 'Bearer otp-access-token') + expect(mocks.rpc).toHaveBeenCalledWith('verify_email_otp_auth') + }) + + it('rejects sessions without OTP authentication evidence', async () => { + const context = {} as Parameters[0] + mocks.rpc.mockResolvedValue({ data: false, error: null }) + + await expect(verifyEmailOtpAuthSession(context, 'password-access-token')).resolves.toEqual({ + verified: false, + error: null, + }) + }) + + it('surfaces RPC errors as failed verification', async () => { + const context = {} as Parameters[0] + const error = { message: 'rpc failed' } + mocks.rpc.mockResolvedValue({ data: null, error }) + + await expect(verifyEmailOtpAuthSession(context, 'otp-access-token')).resolves.toEqual({ + verified: false, + error, + }) + }) +})