Skip to content

Commit 894eba8

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

File tree

6 files changed

+392
-2
lines changed

6 files changed

+392
-2
lines changed

.claude/skills/setup-mocksaml/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,5 +201,5 @@ they can open that URL in a browser to complete the MockSAML login
201201
## Teardown
202202

203203
No teardown needed. Be aware:
204-
- `supabase stop && supabase start` replaces the auth container (loses SAML config) — rerun this skill
205-
- `supabase db reset` wipes SSO provider registrations — rerun from step 8
204+
- `supabase stop && supabase start` replaces the auth container (loses SAML env vars) — use `./supabase/start-with-saml.sh` instead, which re-injects them automatically
205+
- `supabase db reset` wipes SSO provider registrations — use `./supabase/start-with-saml.sh --reset` then rerun from step 8

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

supabase/migrations/00_polyfill.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ BEGIN
4040
create table auth.sso_providers (
4141
id uuid primary key
4242
);
43+
create table auth.sso_domains (
44+
id uuid primary key default gen_random_uuid(),
45+
sso_provider_id uuid references auth.sso_providers(id),
46+
domain text not null
47+
);
4348
create table auth.identities (
4449
user_id uuid references auth.users(id),
4550
provider text,
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 wil be handled separately
10+
-- (manually removed at first, perhaps automatically filtered later).
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;

supabase/start-with-saml.sh

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bash
2+
# Start local Supabase with SAML enabled via MockSAML.
3+
#
4+
# `supabase stop && supabase start` recreates the auth container from scratch,
5+
# stripping any env vars injected by setup-mocksaml. This script runs that
6+
# cycle and then re-injects the SAML env vars and ensures the MockSAML provider
7+
# is registered — no need to rerun the full skill.
8+
#
9+
# Usage: ./supabase/start-with-saml.sh [--reset]
10+
# --reset Run `supabase db reset` instead of stop/start (wipes DB data)
11+
12+
set -euo pipefail
13+
14+
# Fixed dev-only RSA private key (PKCS#1, base64). Safe to commit — not used in production.
15+
SAML_KEY="MIIEpAIBAAKCAQEA1ZjTGI7UUCESupdcHDl+50qvPG0vwxPEOJaTJyuuD+8stL4yJ4+OQqNDKKF/0D3b+750MiSPIR3a9EjLeb9gwhuRqOZb0CrSN8Ra72wbzq4WxzhhsMIQoVDjuxSLebOnvT6O8/UJLVEh0cXGnuFAsHL9LJxG1MDsR+tQMp9kUvP4o2x2AqDA9/9faiM5d8WRjhSTldywqI9xMre2o3VKgJnt8zdJcWMG6oVvjVI+DZihCKKx4NUkIiASQuNUIAPdaNbOJt0xIDUtncDRTvtgLH0mw/YFtN+JiI49wTTi8aVlsJzdG4MlqCoeQuHtMwy7NvkGOuCJWNul3LVNrJ9OBQIDAQABAoIBAQCqMceifcx2vKOrwgdHBhk0OrI+t9Gi4xEq9e/y+j8Lu0woCJT2KND2EBjnOygYyfGLOkpj2fWiMaPRml9ndzKl+EmsB0GJMVWn0fGTbNULbnP/8dEFgty1bTrISqqIIrq5dCt7//d8AHWuLKUC3AErl7Rb43oc9ExRUhLpA1BxNzgq2l7rHKGgw6Da30qFqLimBBbZ4ZWdQDkiFlS4QbxasCR5aDwwR18Lbig2pwBY5IACjLlmgtEAFIKDGaulCrBhqe6FJKZaWXJGmIy74PX+G90lfhfAc6IEJuiQ9lblzKqrlEYHR9tBPfVn7er2+4gFT8XKOF8UKwKEtx71ic4BAoGBAPMtSl0J4fi0uqjuZwT63G84JxrY8ynUUPBauHayIRh5fUCxHAX/CjTIXINPSZkANzBWSUdb2azxzEoEzjGcT5Nv2UuA6Ho5Gbmk+ilIS2f16kSz1G5nohjh9sTJ6+Pqv5NT/RAGGw8Vl7Xphrr3Ep5L7kgH6HRrRPpoAvzC9ighAoGBAODcOgMjohIQxKfmvh6ygbkFIi1joPR4e6c1OPdHAnrz7oA6QmBUJpXDG4MDvTFglDRS/EWeUQYHVhpeTMDJrbZ9L2VHKxz+cX7p4O+oLn/gDFjUhcThh2T28j2b7jmogCAXkLaxIoB1JuVthJjTcmvSbulykjl1KEaMKTCDzlllAoGBAKzgktBH2VUVLuov6h85NIMA+ZP1jhE7tnrZE/CWPD8JB4l5H8IHiTrzAgn70QerhpCflyLa4oo3sBMjDW9pf40CZAlwUFWryGUZKxs0IR98TRqgebIvjKaB5gwKvN9gIOfdOrgsjwoPQLZ7mWPLiHnu4yxkKtaw9+3JCe6lr5fhAoGAY5YFd1hzse6NOhM+RlgmjavRXCrQoRUvJnmy1gkz9wJLsaybsw/x2sgDSj4Ar3qniJjsM2UPW00qfBkhgwyPC9Bbik4/sOKbn2qzfVCN74Jp1XmiGPUNQtD/rft+QTj4Lb5iEBdZQW0hIeEkJY8YENqs1mUwj5Psl3oB0APAAuUCgYBIAUouSaUSZ+XqKGWIrEUYwxwrhfCjv5ID+X7Q9AbZUUbSyO9z6+ARcZFCOxgkVcBOC33c8wxHI52IZ1mKxQwmEjhQXwt1VwNZtzaQYEb2RaVsVKEUTSrR56vCTYYwLBCJoL3AZBu2pKdXEws1yCw1AC6LY5V6HZVQkuq0Y8nkdA=="
16+
17+
RESET=${1:-}
18+
PSQL="psql postgresql://postgres:postgres@localhost:5432/postgres"
19+
20+
# Detect docker prefix (direct or via Lima VM)
21+
if docker ps &>/dev/null; then
22+
DOCKER="docker"
23+
elif limactl shell tiger docker ps &>/dev/null 2>&1; then
24+
DOCKER="limactl shell tiger docker"
25+
else
26+
echo "Error: cannot reach Docker daemon. Start Docker or your Lima VM first." >&2
27+
exit 1
28+
fi
29+
30+
# ── Step 1: Start Supabase ────────────────────────────────────────────────────
31+
32+
if [[ "$RESET" == "--reset" ]]; then
33+
echo "--- Running supabase db reset ---"
34+
supabase db reset
35+
else
36+
echo "--- Running supabase stop/start ---"
37+
supabase stop
38+
# Remove the auth container if it still exists — supabase stop doesn't remove
39+
# containers that were manually recreated by this script (not tracked by
40+
# docker-compose), which causes a name conflict on the next start.
41+
$DOCKER rm -f supabase_auth_flow &>/dev/null || true
42+
supabase start
43+
fi
44+
45+
# ── Step 2: Inject SAML env vars (skipped if already present) ────────────────
46+
47+
if $DOCKER exec supabase_auth_flow env 2>/dev/null | grep -q "GOTRUE_SAML_ENABLED=true"; then
48+
echo "SAML env vars already present — skipping container recreation."
49+
else
50+
echo "--- Injecting SAML env vars into auth container ---"
51+
52+
IMAGE=$($DOCKER inspect supabase_auth_flow --format '{{.Config.Image}}')
53+
NETWORK=$($DOCKER inspect supabase_auth_flow --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
54+
55+
$DOCKER inspect supabase_auth_flow --format '{{range .Config.Env}}{{println .}}{{end}}' \
56+
| grep -v -E '^(PATH=|API_EXTERNAL_URL=)' \
57+
> /tmp/auth_env.txt
58+
echo "GOTRUE_SAML_ENABLED=true" >> /tmp/auth_env.txt
59+
echo "GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY" >> /tmp/auth_env.txt
60+
echo "API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1" >> /tmp/auth_env.txt
61+
62+
if [[ "$DOCKER" == limactl* ]]; then
63+
limactl copy /tmp/auth_env.txt tiger:/tmp/auth_env.txt
64+
fi
65+
66+
$DOCKER stop supabase_auth_flow && $DOCKER rm supabase_auth_flow
67+
$DOCKER run -d \
68+
--name supabase_auth_flow \
69+
--network "$NETWORK" \
70+
--restart always \
71+
--env-file /tmp/auth_env.txt \
72+
"$IMAGE" auth
73+
74+
sleep 3
75+
if $DOCKER logs supabase_auth_flow --tail 5 2>&1 | grep -q "GoTrue API started"; then
76+
echo "GoTrue is running with SAML enabled."
77+
else
78+
echo "Warning: GoTrue may not have started cleanly. Check logs:"
79+
$DOCKER logs supabase_auth_flow --tail 20
80+
exit 1
81+
fi
82+
fi
83+
84+
# ── Step 3: Ensure MockSAML provider is registered ───────────────────────────
85+
86+
PROVIDER_ID=$($PSQL -t -c "SELECT id FROM auth.sso_providers LIMIT 1;" 2>/dev/null | tr -d ' \n')
87+
88+
if [[ -n "$PROVIDER_ID" ]]; then
89+
echo "MockSAML provider already registered (id: $PROVIDER_ID)."
90+
else
91+
echo "--- Registering MockSAML provider ---"
92+
93+
# Extract the sb_secret_... key from Kong config — Kong translates it to
94+
# the service_role JWT when used as an apikey header.
95+
SERVICE_ROLE_KEY=$($DOCKER exec supabase_kong_flow cat /home/kong/kong.yml \
96+
| grep -o "sb_secret_[A-Za-z0-9_-]*" | head -1 || true)
97+
98+
if [[ -z "$SERVICE_ROLE_KEY" ]]; then
99+
echo "Error: could not determine service role key from Kong config." >&2
100+
exit 1
101+
fi
102+
103+
read -r -p "Email domain to associate with MockSAML [example.com]: " DOMAIN
104+
DOMAIN="${DOMAIN:-example.com}"
105+
106+
RESPONSE=$(curl -s -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \
107+
-H "apikey: $SERVICE_ROLE_KEY" \
108+
-H 'Content-Type: application/json' \
109+
-d "{\"type\":\"saml\",\"metadata_url\":\"https://mocksaml.com/api/saml/metadata\",\"domains\":[\"$DOMAIN\"]}")
110+
111+
PROVIDER_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" 2>/dev/null || true)
112+
113+
if [[ -z "$PROVIDER_ID" ]]; then
114+
echo "Error: failed to register provider. Response: $RESPONSE" >&2
115+
exit 1
116+
fi
117+
118+
echo "MockSAML provider registered (id: $PROVIDER_ID, domain: $DOMAIN)."
119+
fi
120+
121+
# ── Step 4: Link provider to a tenant ────────────────────────────────────────
122+
123+
LINKED_TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE sso_provider_id = '$PROVIDER_ID' LIMIT 1;" 2>/dev/null | tr -d ' \n')
124+
125+
if [[ -n "$LINKED_TENANT" ]]; then
126+
echo "Provider already linked to tenant: $LINKED_TENANT"
127+
else
128+
DEFAULT_TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE tenant NOT LIKE 'ops.%' ORDER BY tenant LIMIT 1;" 2>/dev/null | tr -d ' \n')
129+
130+
echo ""
131+
echo "Available tenants:"
132+
$PSQL -t -c "SELECT tenant FROM public.tenants WHERE tenant NOT LIKE 'ops.%' ORDER BY tenant;" 2>/dev/null | tr -d ' ' | grep -v '^$'
133+
echo ""
134+
read -r -p "Tenant to link to MockSAML provider [$DEFAULT_TENANT]: " TENANT
135+
TENANT="${TENANT:-$DEFAULT_TENANT}"
136+
137+
# Normalise: ensure trailing slash
138+
TENANT="${TENANT%/}/"
139+
140+
$PSQL -c "UPDATE public.tenants SET sso_provider_id = '$PROVIDER_ID' WHERE tenant = '$TENANT';"
141+
echo "Linked provider to tenant: $TENANT"
142+
fi
143+
144+
echo ""
145+
echo "--- Setup complete ---"
146+
echo " Provider ID : $PROVIDER_ID"
147+
echo " SSO login : curl -s -X POST 'http://127.0.0.1:5431/auth/v1/sso' -H 'Content-Type: application/json' -d '{\"provider_id\":\"$PROVIDER_ID\"}'"

0 commit comments

Comments
 (0)