Skip to content

Commit 4bf050a

Browse files
committed
Phase 4c: Soft SSO login nudge (access token hook)
1 parent c8ef3b6 commit 4bf050a

File tree

3 files changed

+198
-0
lines changed

3 files changed

+198
-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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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_required` 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_ids jsonb;
25+
begin
26+
target_user_id = (event->>'user_id')::uuid;
27+
claims = event->'claims';
28+
29+
-- Find distinct SSO provider IDs for tenants that have SSO configured and
30+
-- where this user has grants but lacks the matching SSO identity.
31+
select jsonb_agg(distinct t.sso_provider_id)
32+
into provider_ids
33+
from public.user_grants ug
34+
join public.tenants t on t.tenant ^@ ug.object_role
35+
where ug.user_id = target_user_id
36+
and t.sso_provider_id is not null
37+
and not exists (
38+
select 1 from auth.identities ai
39+
where ai.user_id = target_user_id
40+
and ai.provider = 'sso'
41+
and ai.provider_id = t.sso_provider_id::text
42+
);
43+
44+
if provider_ids is not null then
45+
claims = jsonb_set(claims, '{sso_required}', provider_ids);
46+
else
47+
claims = claims - 'sso_required';
48+
end if;
49+
50+
event = jsonb_set(event, '{claims}', claims);
51+
return event;
52+
end;
53+
$$;
54+
55+
-- The hook is invoked by GoTrue as the supabase_auth_admin role.
56+
grant usage on schema public to supabase_auth_admin;
57+
grant execute on function public.custom_access_token_hook(jsonb) to supabase_auth_admin;
58+
59+
-- The function reads from these tables.
60+
grant select on public.user_grants to supabase_auth_admin;
61+
grant select on public.tenants to supabase_auth_admin;
62+
grant select on auth.identities to supabase_auth_admin;
63+
64+
-- Revoke from anon/authenticated — this is not a user-callable function.
65+
revoke execute on function public.custom_access_token_hook(jsonb) from authenticated, anon;
66+
67+
commit;
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
-- Tests for the custom_access_token_hook that adds sso_required 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+
provider_bigcorp uuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
7+
alice_id uuid = '11111111-1111-1111-1111-111111111111';
8+
bob_id uuid = '22222222-2222-2222-2222-222222222222';
9+
result jsonb;
10+
begin
11+
-- Setup: test users.
12+
insert into auth.users (id, email) values
13+
(alice_id, 'alice@example.com'),
14+
(bob_id, 'bob@example.com')
15+
on conflict do nothing;
16+
17+
-- Setup: two SSO providers.
18+
insert into auth.sso_providers (id) values (provider_acme), (provider_bigcorp)
19+
on conflict do nothing;
20+
21+
-- Tenants: acmeCo and bigcorpCo have SSO configured, openCo does not.
22+
delete from tenants;
23+
insert into tenants (tenant, sso_provider_id) values
24+
('acmeCo/', provider_acme),
25+
('bigcorpCo/', provider_bigcorp),
26+
('openCo/', null);
27+
28+
-- Alice has grants on all three, SSO identity for acmeCo only.
29+
delete from user_grants;
30+
insert into user_grants (user_id, object_role, capability) values
31+
(alice_id, 'acmeCo/', 'admin'),
32+
(alice_id, 'bigcorpCo/', 'read'),
33+
(alice_id, 'openCo/', 'admin');
34+
35+
delete from auth.identities where user_id = alice_id;
36+
insert into auth.identities (user_id, provider, provider_id, identity_data) values
37+
(alice_id, 'sso', provider_acme::text, '{}'::jsonb);
38+
39+
-- Alice should get sso_required with bigcorpCo's provider (she lacks that identity).
40+
select public.custom_access_token_hook(jsonb_build_object(
41+
'user_id', alice_id,
42+
'claims', jsonb_build_object('sub', alice_id)
43+
)) into result;
44+
45+
return next is(
46+
result->'claims'->'sso_required',
47+
jsonb_build_array(provider_bigcorp),
48+
'Alice gets sso_required with bigcorpCo provider ID'
49+
);
50+
51+
-- Bob has grants on acmeCo and bigcorpCo, no SSO identities at all.
52+
insert into user_grants (user_id, object_role, capability) values
53+
(bob_id, 'acmeCo/', 'read'),
54+
(bob_id, 'bigcorpCo/', 'read'),
55+
(bob_id, 'openCo/', 'read');
56+
57+
delete from auth.identities where user_id = bob_id;
58+
59+
select public.custom_access_token_hook(jsonb_build_object(
60+
'user_id', bob_id,
61+
'claims', jsonb_build_object('sub', bob_id)
62+
)) into result;
63+
64+
-- Bob should get both provider IDs (order not guaranteed, so check containment).
65+
return next ok(
66+
result->'claims'->'sso_required' @> jsonb_build_array(provider_acme)
67+
and result->'claims'->'sso_required' @> jsonb_build_array(provider_bigcorp),
68+
'Bob gets sso_required with both provider IDs'
69+
);
70+
return next is(
71+
jsonb_array_length(result->'claims'->'sso_required'),
72+
2,
73+
'Bob has exactly 2 provider IDs in sso_required'
74+
);
75+
76+
-- Give Alice an SSO identity for bigcorpCo too — sso_required should disappear.
77+
insert into auth.identities (user_id, provider, provider_id, identity_data) values
78+
(alice_id, 'sso', provider_bigcorp::text, '{}'::jsonb);
79+
80+
select public.custom_access_token_hook(jsonb_build_object(
81+
'user_id', alice_id,
82+
'claims', jsonb_build_object('sub', alice_id)
83+
)) into result;
84+
85+
return next ok(
86+
result->'claims'->'sso_required' is null,
87+
'Alice with both SSO identities has no sso_required claim'
88+
);
89+
90+
-- Clean up the extra identity.
91+
delete from auth.identities
92+
where user_id = alice_id
93+
and provider_id = provider_bigcorp::text;
94+
95+
-- User with only open-tenant grants: no sso_required.
96+
delete from user_grants where user_id = bob_id;
97+
insert into user_grants (user_id, object_role, capability) values
98+
(bob_id, 'openCo/', 'read');
99+
100+
select public.custom_access_token_hook(jsonb_build_object(
101+
'user_id', bob_id,
102+
'claims', jsonb_build_object('sub', bob_id)
103+
)) into result;
104+
105+
return next ok(
106+
result->'claims'->'sso_required' is null,
107+
'User with only open-tenant grants has no sso_required claim'
108+
);
109+
110+
-- SSO user with matching identity on their own tenant: no nudge.
111+
delete from user_grants where user_id = alice_id;
112+
insert into user_grants (user_id, object_role, capability) values
113+
(alice_id, 'acmeCo/', 'admin');
114+
115+
select public.custom_access_token_hook(jsonb_build_object(
116+
'user_id', alice_id,
117+
'claims', jsonb_build_object('sub', alice_id)
118+
)) into result;
119+
120+
return next ok(
121+
result->'claims'->'sso_required' is null,
122+
'SSO user with matching identity on own tenant is not nudged'
123+
);
124+
125+
return;
126+
end
127+
$$ language plpgsql;

0 commit comments

Comments
 (0)