Skip to content

ADR 0004 — External identity federation (social login + enterprise SSO) ​

  • Status: Proposed
  • Date: 2026-07-18
  • Context: Adds social login (Google, Microsoft, Apple) and enterprise SSO (customer brings their own IdP) to GrydAuth. Supersedes the abandoned GrydAuth.Infrastructure.Auth0 proxy path and the User.Auth0UserId / User.IsSocialUser identity model. Companion design: docs/modules/auth/external-identity-federation.md.

Context ​

GrydAuth today authenticates users from one source: a local password (GeraltArgon2idPasswordHasher, MFA, token_version revocation). Two markets we cannot currently serve:

  1. Social login — consumers/SMB users who expect "Continue with Google / Microsoft / Apple".
  2. Enterprise SSO — a business customer (a tenant) whose employees already authenticate against a corporate directory (Entra ID, Okta, Google Workspace, Ping, ADFS) and who will not accept a second password silo. In B2B this is a procurement gate: no SSO, no deal above a certain contract size.

In both cases GrydAuth is the Service Provider / Relying Party (RP) — it consumes an assertion minted by an external Identity Provider and, on the strength of it, issues its own tokens. GrydAuth does not become an IdP for third parties (that is a separate, larger decision, explicitly out of scope).

The unifying observation that drives this ADR: social login is a special case of enterprise SSO. Both are "authenticate the user somewhere else, trust the result, provision/link locally, issue Gryd tokens." The only differences are where the connection config comes from (a global app setting for Google vs. a per-tenant record for Acme's Okta) and which protocol the provider speaks (OIDC vs. SAML). One abstraction can serve both; two parallel implementations would be a mistake.

What exists today, and why it is being discarded ​

An earlier spike built an Auth0-proxied social path. It has four structural defects, each of which the market and the OAuth security BCP (RFC 9700) treat as an anti-pattern:

Defect (current code)Why it is wrong
Auth0Service.GetSocialUserInfoAsync returns a hardcoded user@example.com after Task.Delay(100)Never implemented; not a foundation to build on.
SocialLoginCommand accepts a provider access token from the client and "validates" it server-sideToken-passing / implicit-style flow. No PKCE, no nonce, no state, exposed to token-substitution and mix-up. RFC 9700 §2 rejects it. The RP must drive the authorization-code flow itself.
User.Auth0UserId is a scalar and IsSocialUser is a booleanA person can hold at most one external identity and cannot have both a password and social login. No account linking. Cannot attach Google + Microsoft + an enterprise IdP to the same human.
Linking is by email (GetByEmailAsync → SetAuth0UserId) with no email_verified gate and no issuer bindingThe canonical account-takeover vector (email-collision linking; nOAuth; account pre-hijacking). Identity must be keyed on (issuer, subject), never email.

Additionally the design is coupled to Auth0 at the domain level (Auth0UserId), which fixes a specific broker into the entity model and forecloses both native federation and any other broker.

Per the owner's direction, the existing code is treated as greenfield to replace, not to extend.

What the market does ​

ConcernMarket consensus (2026)Source
Social + OIDC RPAuthorization-code + PKCE mandatory, state+nonce, exact redirect URI, ID-token validation, JWKS rotationRFC 9700 (BCP 240)
Enterprise protocol mixBoth SAML 2.0 and OIDC required. ~half of enterprise connections onboarded by B2B SaaS are still SAML (ADFS, Okta/Ping/Entra gallery apps); OIDC growing fastest but not universalWorkOS, Clerk, Scalekit field data
Identity key(issuer, subject) — never the email claimnOAuth (Entra), MSRC account pre-hijacking
Entra multi-tenantValidate iss as a function of tid; authorize on tid+oid; do not link on emailMicrosoft identity platform docs; nOAuth
AppleClient secret is a self-minted ES256 JWT, ~6-month max, must be rotated; name/email returned only on first authApple developer docs
DeprovisioningJIT provisioning cannot deprovision; enterprises expect SCIM as the follow-upSCIM 2.0 / enterprise questionnaires
Buy optionWorkOS is the reference "enterprise SSO for SaaS" broker; Keycloak the reference self-hosted brokerWorkOS, Keycloak

The relevant finding is not "build" vs "buy" in the abstract, but that every credible option — native, Keycloak, or WorkOS — sits behind the same RP-side seam. Whatever we pick, the application above the seam is identical: normalize an external principal, link/provision, issue Gryd tokens. That makes the seam, not the vendor, the load-bearing decision.

Decision drivers ​

  • Security-first. GrydAuth's differentiator is a hardened auth core (Argon2id, defense-in-depth token validation, Zero Trust, MFA). Federation must not become the soft underbelly. The account-linking and SAML-assertion rules below are not optional hardening; they are the feature.
  • Distribution model. GrydAuth ships as NuGet packages consumed by other developers. A mandatory external runtime dependency (a hosted broker every adopter must buy, or a Keycloak cluster every adopter must operate) fights that model. The default must be self-contained.
  • Low vendor lock-in, but enterprise-sales-ready. These pull in opposite directions only if we let the vendor leak above the seam. They don't if we don't.
  • Reuse, don't fork, the session core. Federated logins must flow into the existing 3-tier token model, token_version revocation, MFA gate, refresh cookie, and Zero Trust — not a parallel session system.
  • Extensible by construction (Open/Closed, DRY). Adding a future issuer (GitHub, LinkedIn, an unknown enterprise IdP) must be a configuration entry or a single new provider class — never a change to the authentication pipeline, the account-linking rules, or token issuance. This is why the design centers on one IExternalIdentityProvider seam and (issuer, subject) identity rather than per-provider code paths. (Companion doc §3.1.)

Options ​

A. Managed broker as the default (WorkOS / Auth0 / Entra External ID) ​

Route all federation through a hosted broker. GrydAuth speaks one OIDC connection to the broker; the broker fans out to every social and enterprise IdP and normalizes the result.

  • For: fastest path to enterprise-ready SAML+SCIM; the broker owns the SAML XSW/replay/cert-rotation burden and the IdP-quirk long tail; excellent onboarding UX (self-serve admin portal). WorkOS is purpose-built for exactly this.
  • Against: every downstream GrydAuth adopter inherits a per-connection cost (~US$125/connection/mo class) and a hosted dependency, which contradicts the NuGet/low-lock-in positioning. User records and login traffic transit a third party — a data-residency/LGPD question for a framework that sells on owning its security. Lock-in is real (connection re-setup on exit).
  • Verdict: wrong as the default for a redistributed framework; right as an optional adapter for adopters who want speed over ownership.

B. Self-hosted broker (Keycloak) as the default ​

Same brokered shape, self-hosted.

  • For: open source, no per-connection fee, no data leaves the deployment, mature SAML+OIDC.
  • Against: Keycloak is an infrastructure component, not a library. Making it the default forces every adopter to deploy and patch an HA Keycloak + Postgres cluster to use GrydAuth — a worse distribution story than a paid SaaS, not a better one. GrydAuth would also be reduced to a thin OIDC client, and its hardened auth core (Argon2id, MFA, token_version) becomes redundant with Keycloak's.
  • Verdict: wrong as the default (distribution mismatch); viable as an optional deployment topology for adopters who already run Keycloak.

Implement the RP side natively in .NET: OIDC (authorization-code + PKCE) for social and modern enterprise, and SAML 2.0 SP (via a vetted library — Sustainsys.Saml2) for the enterprise SAML half. Everything sits behind an IExternalIdentityProvider seam and a per-tenant connection store, so a deployment can back the same seam with a WorkOS or Keycloak adapter (Options A/B) without touching application code.

  • For: self-contained NuGet default; zero mandatory lock-in; the hardened session core is reused, not replaced; social + OIDC enterprise is genuinely cheap on .NET's first-party OIDC handler; the seam preserves A and B as per-deployment choices, so we do not have to pre-commit the build-vs-buy question at all — we ship the sovereign default and let adopters opt into a broker. Follows the module's existing Infrastructure.<provider> sub-package convention (the shell the Auth0 package already established).
  • Against — the two real costs, stated plainly:
    1. We own SAML security maintenance. XML-signature-wrapping, canonicalization, replay, and cert-rotation are a permanent responsibility. Mitigated by never hand-rolling XML/DSig and delegating to a vetted library, but not eliminated.
    2. SAML library dependency (maintenance, not licensing). Both leading .NET SAML SP libraries are permissively licensed — Sustainsys.Saml2 is MIT, ITfoxtec.Identity.Saml2 is BSD-3-Clause — so redistribution in NuGet imposes no copyleft or commercial obligation on adopters (verified against the current license files; an earlier draft wrongly recorded a dual/copyleft risk). The residual cost is therefore choosing and maintaining the library as a security dependency (CVE tracking, version pinning), not a license negotiation. Details and recommendation in companion doc §10 + Appendix A.2.
  • Verdict: best fit for GrydAuth's stated goals, provided we accept SAML ownership or route SAML through the WorkOS adapter behind the seam.

Decision (proposed) ​

Option C. Build the RP natively behind an IExternalIdentityProvider seam and a per-tenant IdentityProviderConnection store; ship native OIDC (social + enterprise) and native SAML (Sustainsys) as the default; keep WorkOS and Keycloak as optional adapters behind the same seam.

Rationale in one line: the seam is the load-bearing decision, not the vendor — so we ship the self-contained, low-lock-in default that matches the NuGet distribution model and reuses the hardened session core, while leaving the broker options open per deployment rather than foreclosing them.

Two decisions are made now because they are hard to reverse later:

  1. Domain model. Replace User.Auth0UserId (scalar) + User.IsSocialUser (bool) with an ExternalIdentity child collection on the User aggregate, keyed unique on (Issuer, Subject), and make the local password an optional credential rather than a mutually-exclusive mode. This enables proper account linking and multi-provider identities and is a schema change; doing it first avoids a second migration. Details in the companion doc.
  2. Flow. All federation is SP-initiated, backend-driven authorization-code (OIDC) / SP-initiated SAML, with PKCE, state, nonce, exact redirect-URI matching, and RFC 9207 iss validation. The client never hands the backend a provider token. This retires the current SocialLoginCommand(accessToken) contract.

Federated authentication terminates in the existing token pipeline — TokenGenerationRequest → JwtTokenService → RefreshTokenService, IEffectivePermissionResolver, the MFA challenge gate, and the global/tenant/ first-login token flow. Federation adds an authentication front door; it does not add a session system.

Sequencing (full plan in the companion doc):

Order signed off 2026-07-18: OIDC first, SCIM next, SAML last.

  1. Phase 0 — Domain model + retire Auth0. ExternalIdentity, drop the binary IsSocialUser, migration, delete the placeholder proxy path and the accessToken command. The old social/Auth0 code is removed, not left as dead code — companion doc Appendix B is the file-level removal inventory (delete / edit-member / rewire, plus the migration-immutability caution and the note that the CircuitBreaker feature is not social-coupled and stays).
  2. Phase 1 — OIDC social (Google, Microsoft work/school only, Apple) with the full §Security control set and secure account linking.
  3. Phase 2 — Enterprise OIDC: per-tenant connections, home-realm discovery on verified domains, JIT provisioning, enforced-SSO, TrustIdpMfa (default true), admin API. Covers Entra/Okta/Workspace/Ping over OIDC.
  4. Phase 3 — SCIM provisioning (real deprovisioning + directory sync), closing the JIT gap. Open item: whether SCIM ships with Phase 2 or immediately after — depends on the first enterprise cohort (companion doc §12 / A.3).
  5. Phase 4 (final) — SAML 2.0 SP (Sustainsys.Saml2, MIT), demand-driven — built when a deal requires it (ADFS / SAML-only IdPs); not licensing-gated. Passkeys/WebAuthn land in parallel as a complement/step-up, off the critical path.

Parameters locked in the 2026-07-18 review: native-by-default; Microsoft work/school only (organizations, personal MSAs rejected); IdP MFA is trusted (TrustIdpMfa default true, per-connection escape hatch). Still open: SCIM timing.

What would change this decision ​

  • Enterprise demand is SAML-heavy and SCIM-on-day-one. If the first cohort of enterprise deals requires SAML + directory sync immediately, the cost of owning SAML + building SCIM may exceed WorkOS's per-connection fee. Then flip the default for SAML/SCIM to the WorkOS adapter (already behind the seam) and keep native OIDC for social. This is a per-deployment switch, not a rewrite — which is the whole point of Option C.
  • No enterprise SAML demand materializes. If customers only ever bring OIDC IdPs, drop Phase 3 entirely. OIDC-only is dramatically simpler and removes the licensing and XSW-maintenance liabilities. Do not build SAML speculatively.
  • We decide not to own SAML security maintenance at all. Licensing is not a blocker (both candidate libraries are permissive — MIT / BSD-3-Clause), so the only reason to avoid native SAML is unwillingness to own the assertion-security surface. In that case SAML goes through the WorkOS/Keycloak adapter and native SAML is dropped.
  • GrydAuth decides to become an IdP (issue "Login with Gryd" to third parties). That reopens the whole design (authorization/token/userinfo endpoints, consent, client registration) and would be its own ADR; it does not change the RP work here but would sit alongside it.

Consequences ​

If accepted:

  • User gains an ExternalIdentity collection; Auth0UserId/IsSocialUser are removed. Breaking schema + wire change, coordinated with @gryd-ui/core, consistent with the v5 no-backward-compatibility precedent.
  • A new per-tenant IdentityProviderConnection aggregate and a verified TenantDomain concept enter the Auth domain, with an admin API to manage them.
  • New federation-specific rate-limit policies, audit events, and secret-encryption usage (client secrets, SAML keys) reusing the MFA encryption approach.
  • GrydAuth takes on responsibility for OIDC/SAML RP security. The control set in the companion doc becomes part of the security test suite (contract tests in the spirit of TokenTypeAllowlistContractTests).
  • A known, documented gap: JIT provisioning without SCIM cannot deprovision. This must be disclosed in security questionnaires until Phase 4.
  • The Infrastructure.Auth0 package is deleted or repurposed as the first optional broker adapter.

If rejected (status quo stands): GrydAuth remains password-only; the social spike stays dead code and should be deleted regardless, since shipping the current email-linked, token-passing path would be a security regression.

References ​

Updated at:

Released under the MIT License.