Skip to content

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

PieceLocationState
SAML SP (metadata / login / ACS)workers/api/src/routes/sso.tsBuilt, mounted on Lambda only
SAML crypto + attribute mappingworkers/api/src/services/saml.tsBuilt (@node-saml/node-saml)
JIT provisioning + guardsworkers/api/src/services/sso-provisioning.tsBuilt
Tenant-admin config APIworkers/api/src/routes/tenant-sso-admin.tsBuilt
Tenant-admin UIapps/web/src/app/tenant-admin/sso/page.tsxBuilt
SSO start pageapps/web/src/app/auth/sso/start/page.tsxBuilt
SSO session callbackapps/web/src/app/auth/sso/page.tsxBuilt
DB tables (tenant_sso_config, tenant_sso_logins)migration 254Applied 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

PurposeURL
SP metadatahttps://api-pdf.gevell.com/api/sso/uth/metadata
SP Entity IDhttps://api-pdf.gevell.com/api/sso/uth/metadata
ACS (Reply URL)https://api-pdf.gevell.com/api/sso/uth/acs
SP-initiated loginhttps://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:

Terminal window
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:

Terminal window
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 Tenant
INSERT INTO public.tenants (slug, display_name, admin_email, support_email, status)
VALUES ('uth', 'UTHealth Houston', 'larry@anglin.com', 'hello@gevell.com', 'active')
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 u
WHERE t.slug = 'uth' AND u.email = 'REPLACE_WITH_UTH_ADMIN@uth.edu'
ON CONFLICT DO NOTHING;

Domain verification gate. allowed_email_domains is the anti-account-takeover control — it is what stops an IdP from asserting someone@gmail.com 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.verified
FROM public.tenants t JOIN public.tenant_domains d ON d.tenant_id = t.id
WHERE 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/metadata ACS / Reply URL: https://api-pdf.gevell.com/api/sso/uth/acs Binding: HTTP-POST NameID: emailAddress SP metadata: https://api-pdf.gevell.com/api/sso/uth/metadata Signed 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:

FieldShibboleth / InCommon (OID form)Microsoft Entra ID
Emailurn:oid:0.9.2342.19200300.100.1.3 (mail)http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
Display nameurn:oid:2.16.840.1.113730.3.1.241http://schemas.microsoft.com/identity/claims/displayname
Principal nameurn:oid:1.3.6.1.4.1.5923.1.1.1.6 (eduPersonPrincipalName).../claims/name
Department / affiliationurn: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 jdoe@uth.tmc.edu rather than a @uth.edu mail value, the domain allowlist will reject the login. Ask what the actual released mail domain(s) are and add every one to allowedEmailDomains.


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)

  1. Sign in at https://pdf.gevell.com as a user with an owner, admin, or manager row in tenant_admins for the uth tenant.
  2. Go to https://pdf.gevell.com/tenant-admin/sso.
  3. 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).
  4. Set allowedEmailDomains = uth.edu (plus any additional released domains).
  5. Set attribute mapping per Step 3.2.
  6. Save with enabled = false. Do not enable yet.

Option B — API (super-admin)

Terminal window
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:

  1. Create departments: INSERT INTO public.departments (tenant_id, name, slug) …
  2. Set departmentAttribute to the released group/affiliation attribute.
  3. Set departmentMap to {"<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.

  1. Set enabled: true.
  2. Have the UTHealth contact open: https://api-pdf.gevell.com/api/sso/uth/login?redirect=/dashboard
  3. 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, ip
FROM public.tenant_sso_logins
WHERE tenant_id = (SELECT id FROM public.tenants WHERE slug = 'uth')
ORDER BY created_at DESC LIMIT 20;
-- The provisioned profile landed in the right tenant
SELECT p.email, p.tenant_id, p.department_id
FROM public.profiles p
JOIN public.tenants t ON t.id = p.tenant_id
WHERE 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 / symptomCauseFix
email_domain_not_allowedReleased mail domain is not in allowedEmailDomainsAdd the real domain (check email column in the audit row)
cross_tenantThat email’s profile already belongs to another tenantMove or clear profiles.tenant_id; SSO will adopt a default-tenant user but never steal another org’s
jit_disabledUnknown user, allow_jit_provisioning = falseEnable JIT, or pre-create the profile
no_emailIdP released no mail attribute and NameID isn’t an emailFix attribute release or email_attribute
no_assertion_idAssertion carried no ID — replay guard can’t protect itIdP-side; hard deny by design
outcome = error, “could not verify your sign-in”Signature / audience / clock failureWrong 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 rowCheck slug + row exists
404 on the login URL itselfHitting api.gevell.com instead of api-pdfUse 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

  1. Confirm the test login succeeded end-to-end and the audit row reads outcome = success.
  2. Leave enabled: true.
  3. Send UTHealth their bookmarkable login URL: https://api-pdf.gevell.com/api/sso/uth/login?redirect=/dashboard (Or point users at https://pdf.gevell.com/auth/sso/start and have them type uth.)
  4. Ask UTHealth to publish the link in their app portal / MyUTH tile.
  5. 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_config
SET 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 users
SELECT s.id, u.email, s.created_at, s.updated_at
FROM auth.sessions s
JOIN auth.users u ON u.id = s.user_id
JOIN public.profiles p ON p.id = u.id
WHERE 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 notAfter at 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_logins
WHERE 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.

GapImpactNotes
No Single Logout (SLO)Signing out of the IdP does not end our sessionAnd nothing else does either — see “Session lifetime” below. This is the sharpest gap in the list
No SP request signingSome IdPs require signed AuthnRequestsAuthnRequestsSigned="false"; would need an SP keypair
No encrypted assertionsAssertion 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: falseOnly the assertion signature is requiredDeliberate — Entra and others don’t always sign the outer response
SSO only on pdf.gevell.comaudit / acr / orgchart have no /auth/sso callbackCross-app SSO is a separate project
Lambda-only mountapi.gevell.com 404s on /api/ssoFine as long as URLs use api-pdf
No SCIM / deprovisioningRemoving a user in the IdP does not disable their account hereManual offboarding; SCIM is future work
No SSO enforcementUsers can still bypass SSO via email OTPsso_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.sessions have not_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: true and autoRefreshToken: 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=3600 cookie in packages/auth/src/resilient-storage.ts is 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_URL pinned on the API Lambda (and added to CDK)
  • uth tenant created; uth.edu domain row verified
  • UTHealth tenant admin has a tenant_admins row
  • 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_logins shows outcome = success
  • Profile provisioned into the uth tenant (correct tenant_id)
  • enabled = true; login URL delivered
  • Cert expiry recorded + 60-day reminder set
  • v1 limitations disclosed to UTHealth security review