Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
30 changes: 29 additions & 1 deletion supabase/functions/_backend/private/verify_email_otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -17,6 +17,15 @@ export const app = createHono('', version)

app.use('/', useCors)

export async function verifyEmailOtpAuthSession(c: Parameters<typeof supabaseClient>[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(' ', '') ?? ''
Expand Down Expand Up @@ -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')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await clearFailedAccountAuth(c, auth.userId)

const otpVerifiedAt = new Date().toISOString()
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/utils/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
118 changes: 118 additions & 0 deletions supabase/tests/58_test_mfa_session_otp_split.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions tests/security-definer-execute-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
58 changes: 58 additions & 0 deletions tests/verify-email-otp-auth-session.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../supabase/functions/_backend/utils/supabase.ts')>()
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<typeof verifyEmailOtpAuthSession>[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<typeof verifyEmailOtpAuthSession>[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<typeof verifyEmailOtpAuthSession>[0]
const error = { message: 'rpc failed' }
mocks.rpc.mockResolvedValue({ data: null, error })

await expect(verifyEmailOtpAuthSession(context, 'otp-access-token')).resolves.toEqual({
verified: false,
error,
})
})
})
Loading