Enterprise SSO (SAML/OIDC) + SCIM — Ops Runbook

Operator guide to turning on enterprise single sign-on and directory provisioning for a customer org. The application code is fully shipped (SSO policy, enforcement gate, SCIM 2.0 endpoints, dashboard config, the /login SSO button and its email-domain IdP discovery). This runbook is the console + dashboard steps ops performs to activate it for a specific customer. There is no per-customer front-end work — Step 3 is a reference, not a task.

Golden rule — order matters. Both discovery and the enforcement gate are inert until you fill Firebase provider id in the dashboard (Step 4), and that one field arms both. Register the provider (Steps 1–2) first, then fill Step 4 with Enforce off, sign in through the SSO button yourself, and only then turn Enforce on. Arming enforcement against a provider that does not actually work is the one way to lock a customer's users out. The org owner is never locked out, so you always have a recovery path.


0. Prerequisites

  • Firebase project on Identity Platform (GCIP). SAML/OIDC providers require the Google Cloud Identity Platform upgrade (Firebase Console → Authentication → "Upgrade to Identity Platform"). This is a billing-tier change — confirm with finance/owner before upgrading. Without it, Step 1 shows no SAML/OIDC option.
  • Customer's IdP metadata (from the customer's IT/IdP admin — Okta, Azure AD, OneLogin, Google Workspace, ADFS, …):
    • SAML: IdP Entity ID, IdP SSO URL (sign-in URL), IdP X.509 signing certificate.
    • OIDC: Issuer URL, Client ID, Client secret.
  • The customer's email domain(s) (e.g. acme.com) — members on these domains will be forced through SSO.
  • Admin access to the Pouchy dashboard as the customer org's owner (or an admin), and access to the Firebase Console for the Pouchy project.

The one value the customer's IdP needs from us — the callback / ACS URL:

https://www.pouchy.ai/__/auth/handler

Note the www. — Pouchy's OAuth authDomain is deliberately www.pouchy.ai (the Apple/Google/SSO callbacks are registered there). Do not use the bare apex for the auth callback. (Everything else developer-facing uses bare pouchy.ai; this callback is the single exception.)


1. Register the provider in Firebase (GCIP)

Firebase Console → Authentication → Sign-in method → Add new provider.

SAML

  1. Choose SAML.
  2. Name it — Firebase assigns a Provider ID of the form saml.<name> (e.g. saml.acme). Write this exact id down — you need it in Step 4.
  3. Fill from the customer metadata: IdP Entity ID, IdP SSO URL, Certificate (X.509).
  4. Callback URL shown by Firebase = the ACS URL you give the customer's IdP. It should be https://www.pouchy.ai/__/auth/handler.
  5. Save + Enable the provider.

OIDC

  1. Choose OpenID Connect.
  2. Name it — Provider ID is oidc.<name> (e.g. oidc.acme). Write it down.
  3. Fill Issuer (URL), Client ID, Client secret; grant type "Code flow".
  4. Callback URL = https://www.pouchy.ai/__/auth/handler.
  5. Save + Enable.

2. Configure the customer's IdP (their side)

This is the OTHER direction: the values you hand the customer's IdP admin so they can create the Pouchy SAML/OIDC app on their side. (The customer, in turn, hands you their Entity ID / SSO URL / cert for Step 1 — as their IT typically provides via a metadata XML.)

Quick reference — SP-side values to give the customer (this Firebase project)

Field (their IdP calls it…) Value
ACS URL / Reply URL / Assertion Consumer Service https://www.pouchy.ai/__/auth/handler
SP Entity ID / Audience URI / Identifier https://mtsocial.firebaseapp.com
NameID format EmailAddress (urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)
Required attribute email — must be released/mapped (the enforcement gate + member matching key off the email domain)

These are the values for the mtsocial Firebase project. Authoritative source: the Firebase provider config screen — when you add the SAML provider in Step 1, Firebase displays the exact Entity ID and ACS/Callback URL it will use; copy those verbatim if they differ from the table (e.g. Firebase may show the …firebaseapp.com/__/auth/handler callback instead of the custom-domain one — use whatever Firebase shows).

What the customer sends you (for Step 1)

  • SAML: their IdP Entity ID, SSO URL (SingleSignOnService Location), and X.509 signing certificate — all present in their IdP's metadata XML (Okta: Applications → the app → Sign On → Identity Provider metadata; Azure/ Entra: Enterprise applications → the app → Single sign-on → SAML, fields "Microsoft Entra Identifier" = Entity ID, "Login URL" = SSO URL, plus the Base64 certificate).
  • OIDC: Issuer URL, Client ID, Client secret.

Have the customer's IdP admin add the Pouchy app + assign the relevant users.


3. The SSO login entry point (no front-end work per customer)

Nothing to wire per customer. /login's "Sign in with organization SSO" button asks for a work email, resolves the org's IdP from the email DOMAIN, and redirects. Both families are handled — signInWithSso routes an oidc. id to OAuthProvider and a saml. id to SAMLAuthProvider.

This used to be a hardcoded provider id, which worked for exactly one tenant and locked out every other SAML org and every OIDC org. It is now discovery:

GET /api/auth/sso-discovery?email=ada@acme.com
  → { "sso": true, "providerId": "saml.acme", "domain": "acme.com" }
  → { "sso": false, "reason": "none" | "ambiguous" | "invalid_domain" | "rate_limited" }

The endpoint is public (it has to answer before anyone is signed in) and rate-limited per IP. It answers only for orgs that are enabled AND carry a Firebase provider id — the same arming condition enforcement uses, so a half-configured org can never send someone into auth/operation-not-allowed. It returns the provider id and nothing else about the org.

A domain claimed by two armed orgs under different providers is refused (ambiguous), not guessed: Allowed email domains is owner-asserted with no domain-ownership proof, so picking a winner would let one org's config decide where another org's staff are sent. Those customers use the deep link below.

Enterprise deep links (the fallback)

Hand a customer a bookmarkable sign-in URL when discovery can't serve them — a domain not (or not yet) in Allowed email domains, an org spanning many domains, or a contested domain:

link behaviour
https://pouchy.ai/login?sso=saml.acme goes straight to that provider, no email step
https://pouchy.ai/login?sso_domain=acme.com prefills the domain and runs discovery
https://pouchy.ai/login?sso=saml.acme&next=/dashboard/usage same, landing on a specific page

?sso= is shape-validated before it reaches Firebase. It is not a bypass: an id with no Console registration cannot authenticate anyone, so the worst a bad link does is show "identity provider is not active yet".

Step 4 arms two things with one field. Filling in the Firebase provider id makes the org discoverable at /login and, if Enforce is on, starts blocking non-SSO access. Turn Enforce on only once you have signed in through the SSO path yourself.


4. Arm enforcement in the dashboard (LAST step)

Pouchy Dashboard → People / 成员 → "Enterprise SSO + SCIM" card (signed in as the org owner/admin):

  1. SSO enabled — on.
  2. Enforce SSO for allowed domains — on.
  3. Protocol — SAML or OIDC (matching Step 1).
  4. Allowed email domains — the customer domain(s), e.g. acme.com.
  5. Firebase provider id — the exact id from Step 1 (saml.acme / oidc.acme). This field is the arming switch, for both halves: /login discovery cannot find the org without it, and enforcement does nothing until it matches the token's sign-in provider.
  6. Save.

To offer SSO without forcing it (the usual first step for a new customer), fill in 1, 3, 4 and 5 and leave Enforce off: staff can sign in through the SSO button, and personal accounts still work.

The moment you save with a valid provider id, enforcement is live: a non-owner member on an allowed domain who signs in with a personal Google/Apple/password account gets 403 sso_required on org access and must use the SSO button.


5. Verify

  1. Owner path: you (the owner) can still access everything regardless — the owner is permanently exempt.
  2. Enforced member, wrong path: have a test member on the allowed domain sign in with personal Google → they should be blocked from the org (403 sso_required).
  3. Enforced member, SSO path: the same member via the SSO button → allowed.
  4. Non-domain member: a member on a different domain is unaffected (not forced).
  5. Discovery: before touching a browser, confirm the domain resolves — curl -s 'https://pouchy.ai/api/auth/sso-discovery?domain=acme.com' must return your providerId. If it returns {"sso":false,"reason":"none"}, the org is not armed (Step 4 fields 1, 4, 5) and the SSO button will tell the visitor no SSO is configured. "ambiguous" means a second org claims the same domain — fix the duplicate claim or give the customer a ?sso= link.

If you don't have a customer IdP yet, test with a free sandbox IdP (Okta developer org, or samltest.id) registered as a throwaway saml.test provider against a test domain.


6. SCIM directory provisioning (optional, independent of SSO login)

Lets the customer's directory (Okta/Azure AD/OneLogin) auto-create, update, and deactivate Pouchy members. Works today, standalone.

  1. Dashboard → People → SSO card → Mint SCIM token. Copy the one-time scim_… token and the Base URL (https://pouchy.ai/scim/v2). Minting rotates any previous token.
  2. In the customer's IdP SCIM/provisioning config:
    • SCIM base URL: https://pouchy.ai/scim/v2
    • Auth: HTTP Header / Bearer token = the minted scim_… token.
  3. The IdP will POST /Users (create → an invited member), PATCH /Users/{id} with active:false (disable → member removed), and GET /Users?filter=….
  4. Provisioned members show up in the members table (invited/pending until they first sign in, then active). SCIM can only touch this org's org_members — it can never exceed what the owner can do by hand.
  5. Every membership change the IdP makes is written to the org audit log (dashboard → Logs, org tab; GET /v1/org/audit) with the same event types the console writes — member.invited, member.role_changed, member.removed — under the actor scim. That actor is how you tell an IdP-driven change from one a console member made by hand; the token mint itself is filed separately as sso.scim_token_minted. Audit rows are retained one year. Note a no-op re-sync files nothing: only a change that actually persisted writes a row, so a roster the IdP re-pushes hourly does not flood the trail.

To revoke SCIM: mint a new token (invalidates the old one) or turn off SCIM via the config. If a token is believed leaked, re-mint and read the org audit log for scim-actor rows over the exposure window — that is the record of anything it did.


7. Rollback / disable

  • Disable enforcement without losing config: dashboard → SSO card → turn off Enforce (or SSO enabled), Save. Users can sign in normally again.
  • Fully disarm instantly: clear the Firebase provider id field + Save — the gate goes inert (same as never-configured), enforcement stops, no member is blocked. (This is the fastest "unlock everyone" lever.)
  • Owner is never affected, so an owner can always reach the dashboard to disarm even mid-incident.
  • Removing the Firebase provider in the Console also breaks the SSO login button — disarm in the dashboard first, then remove the provider, to avoid a window where enforced users can neither SSO in nor fall back.

8. Troubleshooting

Symptom Likely cause Fix
No SAML/OIDC option in Console Project not on Identity Platform Upgrade (Step 0)
auth/operation-not-allowed on SSO sign-in Provider not enabled, or id typo Enable in Console; ensure the dashboard firebaseProviderId matches the Console id exactly
SSO button says "no single sign-on is configured for this email domain" Org not discoverable: enabled off, domain missing from Allowed domains, or provider id blank Fix the three arming fields (Step 4); verify with the Step 5 curl
SSO button says "more than one organization claims this email domain" Two armed orgs list the same domain under different provider ids — discovery refuses rather than guessing Remove the duplicate claim, or hand the customer a ?sso=<providerId> deep link
Enforced users can't get in at all Armed enforcement before the provider actually worked Disarm (clear provider id or turn Enforce off) until an SSO sign-in succeeds
SSO sign-in works but user still blocked firebaseProviderId in dashboard ≠ token's sign_in_provider Make them identical (saml.<name>)
Member on domain not forced domain not in Allowed domains, or Enforce off, or provider id blank Fix the three arming fields
Owner locked out Not possible by design If it appears so, it's a different 403 — check the error code
SCIM calls 401 Wrong/rotated token Re-mint + reconfigure the IdP

What the platform enforces (for reference)

  • Gate lives at the org-access-resolution layer (requireProjectAccessssoAccessBlocked), not a global login wall — it only gates access to the enforcing org's resources.
  • Never grants access — it can only require SSO for a matching domain.
  • Owner exempt, inert until firebaseProviderId is set, only enforced domains — three independent safety conditions, all must hold to block.