SSO Runbook — UTHealth Houston (uth.edu)
Step-by-step procedure for onboarding UTHealth Houston (uth.edu) onto Gevell’s per-tenant SAML 2.0 single sign-on.
Good news: the SAML Service Provider is already built and deployed. This is a configuration + coordination exercise, not a development project. The only code change required is pinning one environment variable (Step 1.1).
Related: SSO architecture · issue #1974 · migration
20260816020000_254_tenant_sso.sql
0. What already exists
| Piece | Location | State |
|---|---|---|
| SAML SP (metadata / login / ACS) | workers/api/src/routes/sso.ts | Built, mounted on Lambda only |
| SAML crypto + attribute mapping | workers/api/src/services/saml.ts | Built (@node-saml/node-saml) |
| JIT provisioning + guards | workers/api/src/services/sso-provisioning.ts | Built |
| Tenant-admin config API | workers/api/src/routes/tenant-sso-admin.ts | Built |
| Tenant-admin UI | apps/web/src/app/tenant-admin/sso/page.tsx | Built |
| SSO start page | apps/web/src/app/auth/sso/start/page.tsx | Built |
| SSO session callback | apps/web/src/app/auth/sso/page.tsx | Built |
DB tables (tenant_sso_config, tenant_sso_logins) | migration 254 | Applied in prod (verified 2026-08-20) |
uth tenant | — | Does not exist yet (prod has only the default tenant) |
Architecture in one paragraph
We are our own SAML SP — we do not use Supabase native SSO. UTHealth’s IdP signs
an assertion; our ACS endpoint verifies the signature against their stored X.509
cert, checks the audience, enforces a verified-email-domain allowlist, JIT-creates
the profile inside the uth tenant, reserves the assertion ID (one-time replay
guard), mints a Supabase session via generateLink → verifyOtp, and hands the
tokens to the frontend in a URL fragment.
Endpoints UTHealth will interact with
Base host is the Lambda API: https://api-pdf.gevell.com
| Purpose | URL |
|---|---|
| SP metadata | https://api-pdf.gevell.com/api/sso/uth/metadata |
| SP Entity ID | https://api-pdf.gevell.com/api/sso/uth/metadata |
| ACS (Reply URL) | https://api-pdf.gevell.com/api/sso/uth/acs |
| SP-initiated login | https://api-pdf.gevell.com/api/sso/uth/login?redirect=/dashboard |
Binding: HTTP-POST for the assertion, HTTP-Redirect for the AuthnRequest.
NameID format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress (default).
Signing: IdP signs the assertion. We do not sign AuthnRequests
(AuthnRequestsSigned="false") and we do not support encrypted assertions in v1.
1. Pre-flight (do before contacting UTHealth)
1.1 Pin PUBLIC_BASE_URL on the API Lambda — REQUIRED
routes/sso.ts builds the SP entity ID and ACS URL from PUBLIC_BASE_URL, and
falls back to the request origin when it is unset. CDK does not currently set it
on the API function (infra/cdk/lib/stacks/api-stack.ts sets FRONTEND_URL but not
PUBLIC_BASE_URL). If the API is ever reached via its raw execute-api hostname,
the advertised entity ID silently changes and every assertion fails the audience
check.
Pin it before handing UTHealth any URLs:
aws lambda update-function-configuration \ --function-name accessible-pdf-prod-api \ --environment "Variables={PUBLIC_BASE_URL=https://api-pdf.gevell.com}"Preferred (durable) fix: add PUBLIC_BASE_URL: config.publicBaseUrl to the
environment block in infra/cdk/lib/stacks/api-stack.ts and redeploy the stack,
so a future cdk deploy does not wipe the manual setting.
Verify:
curl -s https://api-pdf.gevell.com/api/sso/uth/metadata | head -5(Expect Unknown organization. until Step 2 — that is correct; you are confirming
the route exists and the host answers.)
1.2 Confirm the host actually serves /api/sso
ssoRoutes is mounted only in workers/api/src/index-aws.ts (Lambda). It is
not mounted on the Node server (server.ts, api.gevell.com) nor on the
legacy CF worker. Never give UTHealth an api.gevell.com URL — it will 404.
1.3 Confirm the frontend callback host
The ACS hands the session to ${FRONTEND_URL}/auth/sso. In prod FRONTEND_URL is
https://pdf.gevell.com. That means SSO users land on
pdf.gevell.com, and RelayState (?redirect=) must be a same-origin path
on that host. Cross-app SSO (audit, acr, orgchart) is out of scope for this rollout —
those apps have no /auth/sso callback route.
1.4 Decide the tenant slug
Use uth. It appears in every URL UTHealth registers, so it is effectively
permanent — changing it later invalidates their IdP configuration.
2. Create the tenant
Run in the prod Supabase SQL editor. Idempotent.
-- 2.1 TenantINSERT INTO public.tenants (slug, display_name, admin_email, support_email, status)ON CONFLICT (slug) DO NOTHING;
-- 2.2 Email domain, marked verified (see 2.3 before setting verified = true)INSERT INTO public.tenant_domains (tenant_id, domain, is_primary, verified, verified_at)SELECT id, 'uth.edu', TRUE, TRUE, NOW() FROM public.tenants WHERE slug = 'uth'ON CONFLICT DO NOTHING;
-- 2.3 Tenant admin (the UTHealth staff member who will manage SSO).-- The user must have signed in at least once so an auth.users row exists.INSERT INTO public.tenant_admins (tenant_id, user_id, role)SELECT t.id, u.id, 'owner'FROM public.tenants t, auth.users uON CONFLICT DO NOTHING;Domain verification gate. allowed_email_domains is the anti-account-takeover
control — it is what stops an IdP from asserting [email protected] and absorbing
that account. Only mark uth.edu verified once you have confirmation from UTHealth
IAM (a signed email from an @uth.edu address on the engagement thread is sufficient
for a contracted customer; a DNS TXT challenge is stronger).
Confirm:
SELECT t.id, t.slug, d.domain, d.verifiedFROM public.tenants t JOIN public.tenant_domains d ON d.tenant_id = t.idWHERE t.slug = 'uth';3. Exchange metadata with UTHealth IAM
3.1 What we send them
Service Provider: Gevell SP Entity ID:
https://api-pdf.gevell.com/api/sso/uth/metadataACS / Reply URL:https://api-pdf.gevell.com/api/sso/uth/acsBinding: HTTP-POST NameID: emailAddress SP metadata:https://api-pdf.gevell.com/api/sso/uth/metadataSigned AuthnRequests: no · Encrypted assertions: not supported (v1) Single Logout (SLO): not supported (v1)Required attribute release: email (mandatory), display name (recommended), department/affiliation (optional — only if they want department mapping).
3.2 What we need from them
Ask for their IdP metadata URL or XML. Confirm with their IAM team which stack they run — UTHealth may front this with Microsoft Entra ID, a Shibboleth IdP, or both via InCommon. Do not assume; the attribute names differ substantially:
| Field | Shibboleth / InCommon (OID form) | Microsoft Entra ID |
|---|---|---|
urn:oid:0.9.2342.19200300.100.1.3 (mail) | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress | |
| Display name | urn:oid:2.16.840.1.113730.3.1.241 | http://schemas.microsoft.com/identity/claims/displayname |
| Principal name | urn:oid:1.3.6.1.4.1.5923.1.1.1.6 (eduPersonPrincipalName) | .../claims/name |
| Department / affiliation | urn:oid:1.3.6.1.4.1.5923.1.1.1.1 (eduPersonAffiliation) | group claim (object IDs) |
The value stored in email_attribute must be the exact attribute name as it
appears in the assertion — profileToIdentity() looks it up verbatim on the parsed
profile, then falls back to email → mail → the NameID.
If they release the NameID as the email address (common with
nameid-format:emailAddress), you may leave email_attribute NULL entirely.
⚠️ If UTHealth releases an ePPN like
[email protected]rather than a@uth.edumail value, the domain allowlist will reject the login. Ask what the actual released mail domain(s) are and add every one toallowedEmailDomains.
4. Configure the IdP binding
Two routes — the UI is preferred (self-service, and it is what we will hand to future customers).
Option A — Tenant admin UI (preferred)
- Sign in at
https://pdf.gevell.comas a user with anowner,admin, ormanagerrow intenant_adminsfor theuthtenant. - Go to
https://pdf.gevell.com/tenant-admin/sso. - Paste UTHealth’s IdP metadata XML → Parse (calls
POST /api/tenant-admin/sso/parse-metadata, which extracts entity ID, HTTP-Redirect SSO URL, and the signing cert; it does not persist). - Set
allowedEmailDomains=uth.edu(plus any additional released domains). - Set attribute mapping per Step 3.2.
- Save with enabled = false. Do not enable yet.
Option B — API (super-admin)
curl -X PUT https://api-pdf.gevell.com/api/tenant-admin/sso \ -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \ -H "X-Tenant-Id: $UTH_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "idpEntityId": "https://idp.uth.edu/idp/shibboleth", "idpSsoUrl": "https://idp.uth.edu/idp/profile/SAML2/Redirect/SSO", "idpX509Cert": "MIID...", "allowedEmailDomains": ["uth.edu"], "emailAttribute": "urn:oid:0.9.2342.19200300.100.1.3", "nameAttribute": "urn:oid:2.16.840.1.113730.3.1.241", "allowJitProvisioning": true, "enabled": false }'Guard rails enforced by the API: enabled: true is rejected unless
idpEntityId, idpSsoUrl, and idpX509Cert are all set and
allowedEmailDomains is non-empty. Mutations require a managing role — a viewer
gets 403.
Optional — department mapping
If UTHealth wants users foldered by school/department:
- Create departments:
INSERT INTO public.departments (tenant_id, name, slug) … - Set
departmentAttributeto the released group/affiliation attribute. - Set
departmentMapto{"<IdP group value>": "<department slug>"}.
Unmapped values are simply ignored — the user is provisioned without a department.
5. Test before enabling
SSO is off (enabled: false), so /login returns a 403 deny page. Enable it only
for the test window, with a UTHealth IT contact standing by.
- Set
enabled: true. - Have the UTHealth contact open:
https://api-pdf.gevell.com/api/sso/uth/login?redirect=/dashboard - Expect: redirect to the UTHealth IdP → their normal login (+ MFA) → back to
pdf.gevell.com/auth/sso→ landing on/dashboard, signed in.
Verify server-side
SELECT created_at, outcome, reason, email, name_id, ipFROM public.tenant_sso_loginsWHERE tenant_id = (SELECT id FROM public.tenants WHERE slug = 'uth')ORDER BY created_at DESC LIMIT 20;
-- The provisioned profile landed in the right tenantSELECT p.email, p.tenant_id, p.department_idFROM public.profiles pJOIN public.tenants t ON t.id = p.tenant_idWHERE t.slug = 'uth';The same list (last 20) is visible in the tenant-admin UI via
GET /api/tenant-admin/sso.
Deny-reason troubleshooting
reason / symptom | Cause | Fix |
|---|---|---|
email_domain_not_allowed | Released mail domain is not in allowedEmailDomains | Add the real domain (check email column in the audit row) |
cross_tenant | That email’s profile already belongs to another tenant | Move or clear profiles.tenant_id; SSO will adopt a default-tenant user but never steal another org’s |
jit_disabled | Unknown user, allow_jit_provisioning = false | Enable JIT, or pre-create the profile |
no_email | IdP released no mail attribute and NameID isn’t an email | Fix attribute release or email_attribute |
no_assertion_id | Assertion carried no ID — replay guard can’t protect it | IdP-side; hard deny by design |
outcome = error, “could not verify your sign-in” | Signature / audience / clock failure | Wrong cert, wrong SP entity ID, or expired assertion (clock skew allowance is 5s) |
| “This sign-in has already been used.” | Assertion replay (or a double-POST/refresh of the ACS) | Re-initiate login; investigate if repeated |
Unknown organization. (404) | Wrong slug, or no tenant_sso_config row | Check slug + row exists |
| 404 on the login URL itself | Hitting api.gevell.com instead of api-pdf | Use api-pdf.gevell.com |
Where to read errors: deny pages are intentionally vague to the user; the detail
is in tenant_sso_logins.reason and in the structured logs (/admin/logs, service
api, search SSO).
6. Go live
- Confirm the test login succeeded end-to-end and the audit row reads
outcome = success. - Leave
enabled: true. - Send UTHealth their bookmarkable login URL:
https://api-pdf.gevell.com/api/sso/uth/login?redirect=/dashboard(Or point users athttps://pdf.gevell.com/auth/sso/startand have them typeuth.) - Ask UTHealth to publish the link in their app portal / MyUTH tile.
- Record the go-live date, the IdP contact, and the signing certificate expiry (see Step 8).
7. Rollback
SSO is additive — email OTP and OAuth logins keep working for everyone throughout, and there is no “SSO enforced” mode in v1. To disable:
UPDATE public.tenant_sso_configSET enabled = FALSE, status = 'disabled'WHERE tenant_id = (SELECT id FROM public.tenants WHERE slug = 'uth');Effect is immediate (no deploy, no cache). /login and /acs both start returning
403 deny pages.
But already-issued sessions are not affected, and they do not expire on their own (see §9 “Session lifetime” — prod has no absolute session timeout). Disabling SSO stops new logins only. For a hard cut-off you must also revoke the existing sessions:
-- Identify sessions belonging to uth tenant usersSELECT s.id, u.email, s.created_at, s.updated_atFROM auth.sessions sJOIN auth.users u ON u.id = s.user_idJOIN public.profiles p ON p.id = u.idWHERE p.tenant_id = (SELECT id FROM public.tenants WHERE slug = 'uth');Then revoke them in the Supabase dashboard, or via the admin API
(supabase.auth.admin.signOut(jwt) / delete the session rows).
To fully unwind the tenant, delete the tenants row (cascades to
tenant_sso_config, tenant_sso_logins, tenant_domains, tenant_admins) — but
note that user profiles are not deleted, only their tenant_id reference.
8. Ongoing operations
Certificate rotation is the #1 cause of a dead SSO integration. IdP signing certs
typically expire on a 1–3 year cycle; when UTHealth rotates, every login fails at
signature validation until idp_x509_cert is updated.
- Record the cert’s
notAfterat go-live and put a calendar reminder 60 days out:Terminal window echo "-----BEGIN CERTIFICATE-----<cert body>-----END CERTIFICATE-----" | openssl x509 -noout -enddate - Ask UTHealth IAM to notify us before any IdP migration or cert roll.
- Re-paste the new cert via the tenant-admin UI. No deploy required.
Monthly check:
SELECT outcome, reason, count(*), max(created_at)FROM public.tenant_sso_loginsWHERE tenant_id = (SELECT id FROM public.tenants WHERE slug = 'uth') AND created_at > now() - interval '30 days'GROUP BY 1, 2 ORDER BY 3 DESC;9. Known gaps / v1 limitations
Disclose these to UTHealth IAM up front — several are things a university security review will ask about.
| Gap | Impact | Notes |
|---|---|---|
| No Single Logout (SLO) | Signing out of the IdP does not end our session | And nothing else does either — see “Session lifetime” below. This is the sharpest gap in the list |
| No SP request signing | Some IdPs require signed AuthnRequests | AuthnRequestsSigned="false"; would need an SP keypair |
| No encrypted assertions | Assertion travels in the browser POST (over TLS) | Standard for most SAML deployments, but some IdPs mandate encryption |
validateInResponseTo: 'never' | No InResponseTo correlation (Lambda is stateless) | Mitigated by signature + short NotOnOrAfter + one-time assertion-ID index |
wantAuthnResponseSigned: false | Only the assertion signature is required | Deliberate — Entra and others don’t always sign the outer response |
SSO only on pdf.gevell.com | audit / acr / orgchart have no /auth/sso callback | Cross-app SSO is a separate project |
| Lambda-only mount | api.gevell.com 404s on /api/sso | Fine as long as URLs use api-pdf |
| No SCIM / deprovisioning | Removing a user in the IdP does not disable their account here | Manual offboarding; SCIM is future work |
| No SSO enforcement | Users can still bypass SSO via email OTP | sso_enforce was designed in sso-implementation.md but is not implemented |
Session lifetime
An SSO login mints an ordinary Supabase session (generateLink → verifyOtp), so it
inherits the project’s auth settings — there is no SSO-specific TTL.
Verified against prod on 2026-08-20:
- All 2,261 rows in
auth.sessionshavenot_after = NULL→ no absolute session timeout is configured. - Sessions created as far back as 2026-03-10 are still present, with a longest observed continuously-refreshed session of 56 days → no inactivity timeout is being enforced either.
- The client is built with
persistSession: trueandautoRefreshToken: true(packages/auth/src/create-supabase-client.ts), so a session refreshes indefinitely as long as the user keeps visiting.
Net effect: a UTHealth user who signs in via SSO stays signed in indefinitely, and revoking them in the UTHealth IdP does not sign them out of Gevell. Combined with no SLO and no SCIM, offboarding is entirely manual today — delete the session in the Supabase dashboard or disable the user. Expect a university security review to raise this; the fix is to set an absolute session timeout (Dashboard → Authentication → Sessions) and, ideally, implement SLO.
Two red herrings when researching this:
SESSION_EXPIRY_DAYS = "7"in workers/api/wrangler.toml belongs to the legacy CF worker and an org-chart constant. It does not govern the PDF app or the SSO path.- The
max-age=3600cookie inpackages/auth/src/resilient-storage.tsis a fallback storage mirror, not the session lifetime.
The access-token JWT TTL (default 1 hour) is separate — it bounds how long a stolen token works, not how long the login lasts. It is GoTrue config rather than database state, so it can’t be read from SQL; confirm it in Dashboard → Authentication → Sessions.
10. Checklist
-
PUBLIC_BASE_URLpinned on the API Lambda (and added to CDK) -
uthtenant created;uth.edudomain row verified - UTHealth tenant admin has a
tenant_adminsrow - SP coordinates sent to UTHealth IAM
- UTHealth IdP metadata received; IdP stack confirmed (Entra vs Shibboleth)
- Attribute release confirmed — especially the actual mail domain
- Config saved with
enabled = false - Test login succeeded;
tenant_sso_loginsshowsoutcome = success - Profile provisioned into the
uthtenant (correcttenant_id) -
enabled = true; login URL delivered - Cert expiry recorded + 60-day reminder set
- v1 limitations disclosed to UTHealth security review