Skip to content

Commit 8a8f8f5

Browse files
committed
Phase 4c: Soft SSO login nudge (access token hook)
1 parent 6b4fe9d commit 8a8f8f5

File tree

3 files changed

+164
-0
lines changed

3 files changed

+164
-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/custom_access_token_hook"
51+
4852
[auth.email]
4953
# Allow/disallow new user signups via email to your project.
5054
enable_signup = true
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
-- Add a customize_access_token hook that embeds SSO provider IDs into the JWT
2+
-- when a non-SSO user has grants on tenants with SSO configured.
3+
--
4+
-- The dashboard reads the `sso_not_satisfied` claim on login/refresh and can show
5+
-- an interstitial prompting the user to re-authenticate via SSO.
6+
-- Only provider UUIDs are included — no tenant names — to avoid leaking
7+
-- which tenants the user has grants on.
8+
--
9+
-- Keyed on sso_provider_id IS NOT NULL (not enforce_sso) so users get nudged
10+
-- as soon as SSO is configured, giving them runway before hard enforcement.
11+
12+
begin;
13+
14+
create or replace function public.custom_access_token_hook(event jsonb)
15+
returns jsonb
16+
language plpgsql
17+
stable
18+
security definer
19+
set search_path to ''
20+
as $$
21+
declare
22+
claims jsonb;
23+
target_user_id uuid;
24+
provider_id uuid;
25+
begin
26+
target_user_id = (event->>'user_id')::uuid;
27+
claims = event->'claims';
28+
29+
-- Find the SSO provider for the tenant where this user has grants but
30+
-- lacks the matching SSO identity. We expect at most one SSO-enabled
31+
-- tenant per user; LIMIT 1 makes that assumption explicit.
32+
select t.sso_provider_id
33+
into provider_id
34+
from public.user_grants ug
35+
join public.tenants t on ug.object_role ^@ t.tenant
36+
where ug.user_id = target_user_id
37+
and t.sso_provider_id is not null
38+
and not exists (
39+
select 1 from auth.identities ai
40+
where ai.user_id = target_user_id
41+
and ai.provider = 'sso:' || t.sso_provider_id::text
42+
)
43+
limit 1;
44+
45+
if provider_id is not null then
46+
claims = jsonb_set(claims, '{sso_not_satisfied}', to_jsonb(provider_id));
47+
else
48+
claims = claims - 'sso_not_satisfied';
49+
end if;
50+
51+
event = jsonb_set(event, '{claims}', claims);
52+
return event;
53+
end;
54+
$$;
55+
56+
-- The hook is invoked by GoTrue as the supabase_auth_admin role.
57+
grant usage on schema public to supabase_auth_admin;
58+
grant execute on function public.custom_access_token_hook(jsonb) to supabase_auth_admin;
59+
60+
-- The function reads from these tables.
61+
grant select on public.user_grants to supabase_auth_admin;
62+
grant select on public.tenants to supabase_auth_admin;
63+
grant select on auth.identities to supabase_auth_admin;
64+
65+
-- Revoke from anon/authenticated — this is not a user-callable function.
66+
revoke execute on function public.custom_access_token_hook(jsonb) from authenticated, anon;
67+
68+
commit;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
-- Tests for the custom_access_token_hook that adds sso_not_satisfied claim.
2+
create function tests.test_sso_access_token_hook()
3+
returns setof text as $$
4+
declare
5+
provider_acme uuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
6+
alice_id uuid = '11111111-1111-1111-1111-111111111111';
7+
result jsonb;
8+
begin
9+
-- Setup: test user.
10+
insert into auth.users (id, email) values
11+
(alice_id, 'alice@example.com')
12+
on conflict do nothing;
13+
14+
-- Setup: SSO provider for acmeCo.
15+
insert into auth.sso_providers (id) values (provider_acme)
16+
on conflict do nothing;
17+
18+
-- Tenants: acmeCo has SSO configured, openCo does not.
19+
delete from tenants;
20+
insert into tenants (tenant, sso_provider_id) values
21+
('acmeCo/', provider_acme),
22+
('openCo/', null);
23+
24+
-- Alice has grants on both tenants, no SSO identity yet.
25+
delete from user_grants;
26+
insert into user_grants (user_id, object_role, capability) values
27+
(alice_id, 'acmeCo/', 'admin'),
28+
(alice_id, 'openCo/', 'admin');
29+
30+
delete from auth.identities where user_id = alice_id;
31+
32+
-- No SSO identity — should get sso_not_satisfied with acmeCo's provider.
33+
select public.custom_access_token_hook(jsonb_build_object(
34+
'user_id', alice_id,
35+
'claims', jsonb_build_object('sub', alice_id)
36+
)) into result;
37+
38+
return next is(
39+
result->'claims'->'sso_not_satisfied',
40+
to_jsonb(provider_acme),
41+
'Non-SSO user on SSO tenant gets sso_not_satisfied'
42+
);
43+
44+
-- Add SSO identity — sso_not_satisfied should disappear.
45+
insert into auth.identities (user_id, provider, provider_id, identity_data) values
46+
(alice_id, 'sso:' || provider_acme::text, provider_acme::text, '{}'::jsonb);
47+
48+
select public.custom_access_token_hook(jsonb_build_object(
49+
'user_id', alice_id,
50+
'claims', jsonb_build_object('sub', alice_id)
51+
)) into result;
52+
53+
return next ok(
54+
result->'claims'->'sso_not_satisfied' is null,
55+
'SSO user on own tenant has no sso_not_satisfied claim'
56+
);
57+
58+
-- Only open-tenant grants: no sso_not_satisfied.
59+
delete from user_grants where user_id = alice_id;
60+
delete from auth.identities where user_id = alice_id;
61+
insert into user_grants (user_id, object_role, capability) values
62+
(alice_id, 'openCo/', 'admin');
63+
64+
select public.custom_access_token_hook(jsonb_build_object(
65+
'user_id', alice_id,
66+
'claims', jsonb_build_object('sub', alice_id)
67+
)) into result;
68+
69+
return next ok(
70+
result->'claims'->'sso_not_satisfied' is null,
71+
'User with only open-tenant grants has no sso_not_satisfied claim'
72+
);
73+
74+
-- Sub-prefix grant on acmeCo/reports/ should still trigger sso_not_satisfied.
75+
delete from user_grants where user_id = alice_id;
76+
insert into user_grants (user_id, object_role, capability) values
77+
(alice_id, 'acmeCo/reports/', 'read');
78+
79+
select public.custom_access_token_hook(jsonb_build_object(
80+
'user_id', alice_id,
81+
'claims', jsonb_build_object('sub', alice_id)
82+
)) into result;
83+
84+
return next is(
85+
result->'claims'->'sso_not_satisfied',
86+
to_jsonb(provider_acme),
87+
'Sub-prefix grant on acmeCo/reports/ triggers sso_not_satisfied'
88+
);
89+
90+
return;
91+
end
92+
$$ language plpgsql;

0 commit comments

Comments
 (0)