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
4 changes: 2 additions & 2 deletions .claude/skills/setup-mocksaml/SKILL.md
Copy link
Contributor Author

@GregorShear GregorShear Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will probably remove this skill in favor of the start-with-saml.sh script below

Original file line number Diff line number Diff line change
Expand Up @@ -201,5 +201,5 @@ they can open that URL in a browser to complete the MockSAML login
## Teardown

No teardown needed. Be aware:
- `supabase stop && supabase start` replaces the auth container (loses SAML config) — rerun this skill
- `supabase db reset` wipes SSO provider registrations — rerun from step 8
- `supabase stop && supabase start` replaces the auth container (loses SAML env vars) — use `./supabase/start-with-saml.sh` instead, which re-injects them automatically
- `supabase db reset` wipes SSO provider registrations — use `./supabase/start-with-saml.sh --reset` then rerun from step 8
4 changes: 4 additions & 0 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ jwt_expiry = 604800
# Allow/disallow new user signups to your project.
enable_signup = true

[auth.hook.custom_access_token]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for dev environment - will have to configure this via the supabase dashboard in production

enabled = true
uri = "pg-functions://postgres/public/check_sso_requirement"

[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
Expand Down
5 changes: 5 additions & 0 deletions supabase/migrations/00_polyfill.sql
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ BEGIN
create table auth.sso_providers (
id uuid primary key
);
create table auth.sso_domains (
id uuid primary key default gen_random_uuid(),
sso_provider_id uuid references auth.sso_providers(id),
domain text not null
);
create table auth.identities (
user_id uuid references auth.users(id),
provider text,
Expand Down
84 changes: 84 additions & 0 deletions supabase/migrations/20260320120000_check_sso_requirement_hook.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
-- Access token hook that blocks social login for users whose email domain
-- matches an SSO-enforcing tenant's configured domain.
--
-- When enforce_sso is true and the user's email domain appears in
-- auth.sso_domains for that tenant's provider, the hook refuses to mint
-- a token — returning an error that tells the frontend to redirect to SSO.
--
-- Users whose email domain does NOT match (e.g. contractors, partners)
-- are not blocked here; their grants wil be handled separately
-- (manually removed at first, perhaps automatically filtered later).

begin;

-- Add enforce_sso column to tenants. When true, SSO is required for
-- users whose email domain matches the tenant's SSO provider domains.
alter table public.tenants
add column if not exists enforce_sso boolean not null default false;

comment on column public.tenants.enforce_sso is
'When true, users whose email domain matches this tenant''s SSO provider '
'domains must authenticate via SSO. Social login is blocked for these users.';

create or replace function public.check_sso_requirement(event jsonb)
returns jsonb
language plpgsql
stable
security definer
set search_path to ''
as $$
declare
target_user_id uuid;
user_email text;
user_domain text;
user_is_sso boolean;
begin
target_user_id = (event->>'user_id')::uuid;

select u.email, u.is_sso_user
into user_email, user_is_sso
from auth.users u
where u.id = target_user_id;

-- SSO users pass through unconditionally.
if user_is_sso then
return event;
end if;

user_domain = split_part(user_email, '@', 2);

-- Check whether this user's email domain matches an SSO-enforcing tenant.
-- If so, block token issuance — the user should be logging in via SSO.
if exists (
select 1
from auth.sso_domains sd
join public.tenants t on t.sso_provider_id = sd.sso_provider_id
where t.enforce_sso = true
and sd.domain = user_domain
) then
return jsonb_build_object(
'error', jsonb_build_object(
'http_code', 403,
'message', 'sso_required:' || user_domain
)
);
end if;

return event;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the exception handler so exceptions will bubble up to the caller - is this the right approach? is there any specific monitoring we need to set up or are all exceptions like readily visible? (where would i look for these anyway?)

end;
$$;

-- The hook is invoked by GoTrue as the supabase_auth_admin role.
grant usage on schema public to supabase_auth_admin;
grant execute on function public.check_sso_requirement(jsonb) to supabase_auth_admin;

-- The function reads from these tables.
grant select on public.tenants to supabase_auth_admin;
grant select on auth.users to supabase_auth_admin;
grant select on auth.sso_domains to supabase_auth_admin;

-- Anon and authenticated roles have execute privileges by default - revoke them.
-- check_sso_requirement is exclusively for supabase_auth_admin.
revoke execute on function public.check_sso_requirement(jsonb) from authenticated, anon;

commit;
150 changes: 150 additions & 0 deletions supabase/start-with-saml.sh
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% claude generated script for setting up mocksaml in a dev environment. it works on my machine... :)

Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Start local Supabase with SAML enabled via MockSAML.
#
# `supabase stop && supabase start` recreates the auth container from scratch,
# stripping any env vars injected by setup-mocksaml. This script runs that
# cycle and then re-injects the SAML env vars and ensures the MockSAML provider
# is registered — no need to rerun the full skill.
#
# Usage: ./supabase/start-with-saml.sh [--reset]
# --reset Run `supabase db reset` instead of stop/start (wipes DB data)

set -euo pipefail

# Generate a SAML signing key (PKCS#1).
# GoTrue needs this to sign outgoing SAMLRequests.
SAML_KEY=$(openssl genrsa -traditional 2048 2>/dev/null \
| grep -v "^-----" \
| tr -d '\n')

RESET=${1:-}
PSQL="psql postgresql://postgres:postgres@localhost:5432/postgres"

# Detect docker prefix (direct or via Lima VM)
if docker ps &>/dev/null; then
DOCKER="docker"
elif limactl shell tiger docker ps &>/dev/null 2>&1; then
DOCKER="limactl shell tiger docker"
else
echo "Error: cannot reach Docker daemon. Start Docker or your Lima VM first." >&2
exit 1
fi

# ── Step 1: Start Supabase ────────────────────────────────────────────────────

if [[ "$RESET" == "--reset" ]]; then
echo "--- Running supabase db reset ---"
supabase db reset
else
echo "--- Running supabase stop/start ---"
supabase stop
# Remove the auth container if it still exists — supabase stop doesn't remove
# containers that were manually recreated by this script (not tracked by
# docker-compose), which causes a name conflict on the next start.
$DOCKER rm -f supabase_auth_flow &>/dev/null || true
supabase start
fi

# ── Step 2: Inject SAML env vars (skipped if already present) ────────────────

if $DOCKER exec supabase_auth_flow env 2>/dev/null | grep -q "GOTRUE_SAML_ENABLED=true"; then
echo "SAML env vars already present — skipping container recreation."
else
echo "--- Injecting SAML env vars into auth container ---"

IMAGE=$($DOCKER inspect supabase_auth_flow --format '{{.Config.Image}}')
NETWORK=$($DOCKER inspect supabase_auth_flow --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')

$DOCKER inspect supabase_auth_flow --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -v -E '^(PATH=|API_EXTERNAL_URL=)' \
> /tmp/auth_env.txt
echo "GOTRUE_SAML_ENABLED=true" >> /tmp/auth_env.txt
echo "GOTRUE_SAML_PRIVATE_KEY=$SAML_KEY" >> /tmp/auth_env.txt
echo "API_EXTERNAL_URL=http://127.0.0.1:5431/auth/v1" >> /tmp/auth_env.txt

if [[ "$DOCKER" == limactl* ]]; then
limactl copy /tmp/auth_env.txt tiger:/tmp/auth_env.txt
fi

$DOCKER stop supabase_auth_flow && $DOCKER rm supabase_auth_flow
$DOCKER run -d \
--name supabase_auth_flow \
--network "$NETWORK" \
--restart always \
--env-file /tmp/auth_env.txt \
"$IMAGE" auth

sleep 3
if $DOCKER logs supabase_auth_flow --tail 5 2>&1 | grep -q "GoTrue API started"; then
echo "GoTrue is running with SAML enabled."
else
echo "Warning: GoTrue may not have started cleanly. Check logs:"
$DOCKER logs supabase_auth_flow --tail 20
exit 1
fi
fi

# ── Step 3: Ensure MockSAML provider is registered ───────────────────────────

PROVIDER_ID=$($PSQL -t -c "SELECT id FROM auth.sso_providers LIMIT 1;" 2>/dev/null | tr -d ' \n')

if [[ -n "$PROVIDER_ID" ]]; then
echo "MockSAML provider already registered (id: $PROVIDER_ID)."
else
echo "--- Registering MockSAML provider ---"

# Extract the sb_secret_... key from Kong config — Kong translates it to
# the service_role JWT when used as an apikey header.
SERVICE_ROLE_KEY=$($DOCKER exec supabase_kong_flow cat /home/kong/kong.yml \
| grep -o "sb_secret_[A-Za-z0-9_-]*" | head -1 || true)

if [[ -z "$SERVICE_ROLE_KEY" ]]; then
echo "Error: could not determine service role key from Kong config." >&2
exit 1
fi

read -r -p "Email domain to associate with MockSAML [example.com]: " DOMAIN
DOMAIN="${DOMAIN:-example.com}"

RESPONSE=$(curl -s -X POST 'http://127.0.0.1:5431/auth/v1/admin/sso/providers' \
-H "apikey: $SERVICE_ROLE_KEY" \
-H 'Content-Type: application/json' \
-d "{\"type\":\"saml\",\"metadata_url\":\"https://mocksaml.com/api/saml/metadata\",\"domains\":[\"$DOMAIN\"]}")

PROVIDER_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" 2>/dev/null || true)

if [[ -z "$PROVIDER_ID" ]]; then
echo "Error: failed to register provider. Response: $RESPONSE" >&2
exit 1
fi

echo "MockSAML provider registered (id: $PROVIDER_ID, domain: $DOMAIN)."
fi

# ── Step 4: Link provider to a tenant ────────────────────────────────────────

LINKED_TENANT=$($PSQL -t -c "SELECT tenant FROM public.tenants WHERE sso_provider_id = '$PROVIDER_ID' LIMIT 1;" 2>/dev/null | tr -d ' \n')

if [[ -n "$LINKED_TENANT" ]]; then
echo "Provider already linked to tenant: $LINKED_TENANT"
else
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')

echo ""
echo "Available tenants:"
$PSQL -t -c "SELECT tenant FROM public.tenants WHERE tenant NOT LIKE 'ops.%' ORDER BY tenant;" 2>/dev/null | tr -d ' ' | grep -v '^$'
echo ""
read -r -p "Tenant to link to MockSAML provider [$DEFAULT_TENANT]: " TENANT
TENANT="${TENANT:-$DEFAULT_TENANT}"

# Normalise: ensure trailing slash
TENANT="${TENANT%/}/"

$PSQL -c "UPDATE public.tenants SET sso_provider_id = '$PROVIDER_ID' WHERE tenant = '$TENANT';"
echo "Linked provider to tenant: $TENANT"
fi

echo ""
echo "--- Setup complete ---"
echo " Provider ID : $PROVIDER_ID"
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\"}'"
Loading
Loading