Skip to main content

Wiring a non-native IdP

CNS's ssojwt identity adapter is IdP-shape-agnostic by design (see Authentication) — it never hardcodes any one provider. This guide works through a concrete, verified example: a tenant whose users authenticate via Supabase Auth instead of a central SSO, based on a real worked recipe from this project's own deployment history.

The gap this closes

A Supabase-issued access token is a normal JWT, but it differs from a typical central-SSO token in two ways ssojwt needed small, additive config knobs for:

  1. No tenant claim. A default Supabase access token carries no CNS-native tenant claim at all.
  2. A different phone claim name. Supabase's claim is phone; ssojwt's default claim-name convention expects phone_number.

Everything else — sub (Supabase's auth.users UUID) and email — already lines up with what identity-mapping needs: it resolves/stitches purely off email/phone, never off the raw sub, so a Supabase user's UUID never needs to match anything on the CNS side.

Config recipe

cns-server picks a signing-key source the same way for any IdP — a static HMAC secret, or a JWKS URL. Supabase supports both:

  • JWKS (recommended for a rotatable setup): https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json once the project is on asymmetric (ES256/RS256) signing keys.
  • HS256 shared secret (legacy): every Supabase project has one under Project Settings → API → JWT Settings — set as CNS_SSO_JWT_HMAC_SECRET if the project hasn't migrated to JWKS-based signing.
# --- pick ONE signing-key source ---

# JWKS (preferred):
export CNS_SSO_JWT_JWKS_URL="https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json"

# OR HS256 (a deployment-time secret from the Supabase project's dashboard —
# never hardcode or commit it):
export CNS_SSO_JWT_HMAC_SECRET="<the project's JWT secret>"

# --- common to either signing-key source ---
export CNS_SSO_JWT_ISSUER="https://<project-ref>.supabase.co/auth/v1"
export CNS_SSO_JWT_AUDIENCE="authenticated" # Supabase's standard aud claim

# Gap 1: no tenant claim — pin this deployment's one tenant (Phase-1 is
# single-tenant per process anyway). Match CNS_DEFAULT_TENANT_ID unless this
# tenant deliberately runs as its own cns-server process with a different id.
export CNS_SSO_JWT_STATIC_TENANT_ID="acme"
export CNS_DEFAULT_TENANT_ID="acme"

# Gap 2: Supabase's phone claim is "phone", not ssojwt's default "phone_number".
# (Email claim name is already "email" on both sides — no override needed.)
export CNS_SSO_JWT_CLAIM_PHONE="phone"

internal/config.Load reads these directly — there's no file-based config in Phase

  1. Every one of these four knobs (CNS_SSO_JWT_STATIC_TENANT_ID, CNS_SSO_JWT_CLAIM_TENANT_ID, CNS_SSO_JWT_CLAIM_EMAIL, CNS_SSO_JWT_CLAIM_PHONE) defaults to unset — meaning "behave exactly like the default SSO-JWT shape" — so adding this recipe for one tenant does not change how any other, natively-configured tenant authenticates.

What this recipe does not decide

  • Which concrete IdP project/URL/secret a given tenant uses in production is a deployment-time value you own — never hardcode it into the codebase.
  • Whether this tenant shares a cns-server process with a natively-SSO'd tenant. Phase 1 loads exactly one global Profile — and therefore one Identity-adapter config — per process (see Adapters & Profiles). If one tenant needs to authenticate via Supabase while another tenant's users keep authenticating via the platform's own SSO at the same time, that's two different Identity-adapter configs, and therefore two different cns-server processes today — not a single-process multi-IdP feature. Per-tenant profile keying (Phase 2) is what removes this constraint; see Advanced & Roadmap.

Verifying the recipe

internal/adapters/identity/ssojwt's test suite includes a case built against exactly this claim shape (a sub UUID, email, phone, no tenant_id) verifying the static-tenant-fallback path end to end — if you're adapting this recipe for a different non-native IdP, that test is a good template for confirming your own claim mapping resolves the way you expect before pointing real traffic at it.