Skip to content

Commit 33c62d9

Browse files
committed
Phase 4c: Reject non-sso logins for sso-enforced domains
1 parent 7b0c8a8 commit 33c62d9

File tree

3 files changed

+238
-0
lines changed

3 files changed

+238
-0
lines changed

supabase/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ jwt_expiry = 604800
4545
# Allow/disallow new user signups to your project.
4646
enable_signup = true
4747

48+
[auth.hook.custom_access_token]
49+
enabled = true
50+
uri = "pg-functions://postgres/public/check_sso_requirement"
51+
4852
[auth.email]
4953
# Allow/disallow new user signups via email to your project.
5054
enable_signup = true
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
-- Access token hook that blocks social login for users whose email domain
2+
-- matches an SSO-enforcing tenant's configured domain.
3+
--
4+
-- When enforce_sso is true and the user's email domain appears in
5+
-- auth.sso_domains for that tenant's provider, the hook refuses to mint
6+
-- a token — returning an error that tells the frontend to redirect to SSO.
7+
--
8+
-- Users whose email domain does NOT match (e.g. contractors, partners)
9+
-- are not blocked here; their grants on enforcing tenants are filtered
10+
-- out in user_roles() / the GraphQL snapshot instead.
11+
12+
begin;
13+
14+
-- Add enforce_sso column to tenants. When true, SSO is required for
15+
-- users whose email domain matches the tenant's SSO provider domains.
16+
alter table public.tenants
17+
add column if not exists enforce_sso boolean not null default false;
18+
19+
comment on column public.tenants.enforce_sso is
20+
'When true, users whose email domain matches this tenant''s SSO provider '
21+
'domains must authenticate via SSO. Social login is blocked for these users.';
22+
23+
create or replace function public.check_sso_requirement(event jsonb)
24+
returns jsonb
25+
language plpgsql
26+
stable
27+
security definer
28+
set search_path to ''
29+
as $$
30+
declare
31+
target_user_id uuid;
32+
user_email text;
33+
user_domain text;
34+
user_is_sso boolean;
35+
begin
36+
target_user_id = (event->>'user_id')::uuid;
37+
38+
select u.email, u.is_sso_user
39+
into user_email, user_is_sso
40+
from auth.users u
41+
where u.id = target_user_id;
42+
43+
-- SSO users pass through unconditionally.
44+
if user_is_sso then
45+
return event;
46+
end if;
47+
48+
user_domain = split_part(user_email, '@', 2);
49+
50+
-- Check whether this user's email domain matches an SSO-enforcing tenant.
51+
-- If so, block token issuance — the user should be logging in via SSO.
52+
if exists (
53+
select 1
54+
from auth.sso_domains sd
55+
join public.tenants t on t.sso_provider_id = sd.sso_provider_id
56+
where t.enforce_sso = true
57+
and sd.domain = user_domain
58+
) then
59+
return jsonb_build_object(
60+
'error', jsonb_build_object(
61+
'http_code', 403,
62+
'message', 'sso_required:' || user_domain
63+
)
64+
);
65+
end if;
66+
67+
return event;
68+
end;
69+
$$;
70+
71+
-- The hook is invoked by GoTrue as the supabase_auth_admin role.
72+
grant usage on schema public to supabase_auth_admin;
73+
grant execute on function public.check_sso_requirement(jsonb) to supabase_auth_admin;
74+
75+
-- The function reads from these tables.
76+
grant select on public.tenants to supabase_auth_admin;
77+
grant select on auth.users to supabase_auth_admin;
78+
grant select on auth.sso_domains to supabase_auth_admin;
79+
80+
-- Anon and authenticated roles have execute privileges by default - revoke them.
81+
-- check_sso_requirement is exclusively for supabase_auth_admin.
82+
revoke execute on function public.check_sso_requirement(jsonb) from authenticated, anon;
83+
84+
commit;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
-- Tests for check_sso_requirement hook that blocks social login
2+
-- when the user's email domain matches an SSO-enforcing tenant.
3+
create function tests.test_sso_access_token_hook()
4+
returns setof text as $$
5+
declare
6+
provider_acme uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
7+
provider_widgetly uuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
8+
alice_id uuid = '11111111-1111-1111-1111-111111111111';
9+
bob_id uuid = '22222222-2222-2222-2222-222222222222';
10+
carol_id uuid = '33333333-3333-3333-3333-333333333333';
11+
result jsonb;
12+
base_event jsonb;
13+
begin
14+
-- Setup: test users.
15+
insert into auth.users (id, email) values
16+
(alice_id, 'alice@acme.com'),
17+
(bob_id, 'bob@gmail.com'),
18+
(carol_id, 'carol@widgetly.io')
19+
on conflict (id) do update set email = excluded.email;
20+
21+
-- Setup: SSO providers and domains.
22+
insert into auth.sso_providers (id) values (provider_acme), (provider_widgetly)
23+
on conflict do nothing;
24+
insert into auth.sso_domains (id, sso_provider_id, domain) values
25+
(gen_random_uuid(), provider_acme, 'acme.com'),
26+
(gen_random_uuid(), provider_widgetly, 'widgetly.io')
27+
on conflict do nothing;
28+
29+
-- Tenants: acmeCo and widgetlyCo enforce SSO, openCo does not.
30+
insert into tenants (tenant, sso_provider_id, enforce_sso) values
31+
('acmeCo/', provider_acme, true),
32+
('widgetlyCo/', provider_widgetly, true),
33+
('openCo/', null, false)
34+
on conflict (tenant) do update set
35+
sso_provider_id = excluded.sso_provider_id,
36+
enforce_sso = excluded.enforce_sso;
37+
38+
-- Ensure all test users start as non-SSO.
39+
update auth.users set is_sso_user = false where id in (alice_id, bob_id, carol_id);
40+
41+
-- =========================================================
42+
-- Case 1: Social user with matching domain → blocked
43+
-- =========================================================
44+
base_event = jsonb_build_object(
45+
'user_id', alice_id,
46+
'claims', jsonb_build_object('sub', alice_id)
47+
);
48+
49+
select public.check_sso_requirement(base_event) into result;
50+
51+
return next is(
52+
result->'error'->>'http_code', '403',
53+
'Social user with matching domain gets 403'
54+
);
55+
return next is(
56+
result->'error'->>'message', 'sso_required:acme.com',
57+
'Error message includes domain'
58+
);
59+
60+
-- =========================================================
61+
-- Case 2: Social user on a different SSO domain → blocked with that domain
62+
-- =========================================================
63+
base_event = jsonb_build_object(
64+
'user_id', carol_id,
65+
'claims', jsonb_build_object('sub', carol_id)
66+
);
67+
68+
select public.check_sso_requirement(base_event) into result;
69+
70+
return next is(
71+
result->'error'->>'message', 'sso_required:widgetly.io',
72+
'Error message includes the users own domain, not a hardcoded one'
73+
);
74+
75+
-- =========================================================
76+
-- Case 3: Social user with non-matching domain → allowed
77+
-- =========================================================
78+
base_event = jsonb_build_object(
79+
'user_id', bob_id,
80+
'claims', jsonb_build_object('sub', bob_id)
81+
);
82+
83+
select public.check_sso_requirement(base_event) into result;
84+
85+
return next ok(
86+
result->'error' is null,
87+
'Social user with non-matching domain is not blocked'
88+
);
89+
return next is(
90+
result->'claims'->>'sub', bob_id::text,
91+
'Non-matching domain user gets claims passed through'
92+
);
93+
94+
-- =========================================================
95+
-- Case 4: SSO user with matching domain → allowed (token refresh)
96+
-- =========================================================
97+
update auth.users set is_sso_user = true where id = alice_id;
98+
99+
base_event = jsonb_build_object(
100+
'user_id', alice_id,
101+
'claims', jsonb_build_object('sub', alice_id)
102+
);
103+
104+
select public.check_sso_requirement(base_event) into result;
105+
106+
return next ok(
107+
result->'error' is null,
108+
'SSO user with matching domain is not blocked'
109+
);
110+
111+
-- =========================================================
112+
-- Case 5: enforce_sso = false → not blocked even with matching domain
113+
-- =========================================================
114+
update auth.users set is_sso_user = false where id = alice_id;
115+
update tenants set enforce_sso = false where tenant = 'acmeCo/';
116+
117+
base_event = jsonb_build_object(
118+
'user_id', alice_id,
119+
'claims', jsonb_build_object('sub', alice_id)
120+
);
121+
122+
select public.check_sso_requirement(base_event) into result;
123+
124+
return next ok(
125+
result->'error' is null,
126+
'Social user with matching domain allowed when enforce_sso is false'
127+
);
128+
129+
-- Restore for remaining tests.
130+
update tenants set enforce_sso = true where tenant = 'acmeCo/';
131+
132+
-- =========================================================
133+
-- Case 6: Malformed user_id propagates as an exception (does not silently pass through)
134+
-- =========================================================
135+
-- 'not-a-uuid' triggers an invalid_text_representation error on the
136+
-- ::uuid cast. With no exception handler, this should raise to the caller.
137+
begin
138+
select public.check_sso_requirement(jsonb_build_object(
139+
'user_id', 'not-a-uuid',
140+
'claims', jsonb_build_object('sub', 'bogus')
141+
)) into result;
142+
143+
return next ok(false, 'Malformed event should have raised an exception');
144+
exception when others then
145+
return next ok(true, 'Malformed event raises an exception (does not silently pass through)');
146+
end;
147+
148+
return;
149+
end
150+
$$ language plpgsql;

0 commit comments

Comments
 (0)