|
| 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