Appearance
External identity federation — social login & enterprise SSO (design)
Status: Proposed — companion to ADR 0004. Scope: GrydAuth as Service Provider / Relying Party only. Covers social login (Google, Microsoft, Apple) and enterprise SSO (customer brings their own IdP). GrydAuth acting as an IdP for third parties is out of scope. Delivery order (signed off 2026-07-18): OIDC first (social, then enterprise), then SCIM, with SAML as the final phase. See §11 and the Decisions log (§12).
1. Principle: one abstraction, two faces
Social login and enterprise SSO are the same operation with different configuration sources:
| Social login | Enterprise SSO | |
|---|---|---|
| GrydAuth role | Relying Party | Relying Party |
| Provider | Google / Microsoft / Apple (public IdP) | Customer's Entra/Okta/Workspace/Ping/ADFS |
| Protocol | OIDC | OIDC or SAML 2.0 |
| Config lives in | global app configuration | per-tenant IdentityProviderConnection |
| Tenant resolution | HRD / preferred / default | the connection's owning tenant |
| Provisioning | JIT (self-serve) | JIT first; SCIM in a dedicated later phase |
Everything downstream of "we obtained a validated external principal" is identical. The design therefore has one pipeline and one seam (IExternalIdentityProvider), parameterized by a connection descriptor. Social providers are simply framework-owned connections.
┌────────────────────────── GrydAuth (RP) ──────────────────────────┐
Browser │ /start ─► ChallengeBuilder ─► (state,nonce,PKCE cached) │
│ │ │
▼ │ redirect to IdP ────────────────────────────────────────────► IdP (Google / Entra /
IdP login│ Okta / ADFS / Apple …)
│ │ ◄──────────────── code | SAMLResponse ─── /callback (ACS) │
▼ │ │ │
│ IExternalIdentityProvider.HandleCallback │
│ OIDC: code+PKCE exchange, ID-token validate (iss fn, aud, │
│ nonce, exp, sig via JWKS, RFC 9207 iss) │
│ SAML: signature vs pinned cert, audience/recipient/InResponseTo, │
│ NotOnOrAfter, replay-cache, XSW-safe │
│ │ │
│ ▼ ExternalPrincipal { Issuer, Subject, Email, │
│ EmailVerified, DisplayName, Claims, ProviderType } │
│ │ │
│ AccountLinker (key on (Issuer,Subject); linking rules §5) │
│ │ │
│ TenantResolver (connection tenant | HRD | preferred | default) │
│ │ │
│ MFA policy gate (existing MfaChallengeInitiator) │
│ │ │
│ ►► EXISTING token pipeline: │
│ TokenGenerationRequest ─► JwtTokenService ─► RefreshToken │
│ (global / tenant / first-login token, token_version, cookie) │
└────────────────────────────────────────────────────────────────────┘The boxed region below ExternalPrincipal is the code we already have. The new work is everything above it, plus the domain model and connection store.
2. Domain model changes
2.1 Retire the binary identity model
Remove from User:
Auth0UserId : string?— scalar external id (one provider only)IsSocialUser : bool— mutually-exclusive local-vs-social mode- the guards that hang off them (
CreateSocialUser,SetAuth0UserId,CanAuthenticateSocially, the "social users cannot set password" throws)
A user's authentication capabilities become derived, not a stored mode:
csharp
public bool CanAuthenticateLocally() => PasswordHash is not null; // has a password
public bool HasExternalIdentities() => _externalIdentities.Count > 0; // ≥1 linked IdPA single human may now hold a password and any number of external identities simultaneously — the precondition for real account linking.
2.2 New: ExternalIdentity (child of the User aggregate)
csharp
public sealed class ExternalIdentity : BaseEntity
{
public Guid UserId { get; private set; }
public ExternalProviderType ProviderType { get; private set; } // Google|Microsoft|Apple|OidcEnterprise|Saml
public string Issuer { get; private set; } // OIDC iss | SAML IdP EntityID
public string Subject { get; private set; } // OIDC sub | SAML NameID (+ format)
public Guid? ConnectionId { get; private set; } // null for global/social; set for enterprise
public string? EmailAtLink { get; private set; } // audit only — NEVER an identity key
public DateTime LinkedAt { get; private set; }
public DateTime? LastUsedAt { get; private set; }
public ExternalLinkMethod LinkMethod { get; private set; } // Jit | ExplicitByUser | VerifiedEmail
}- Primary identity key: unique index on
(Issuer, Subject). This tuple, not email, is what a login is resolved by. Immutable per provider. ConnectionIdties an enterprise identity to the tenant connection that minted it (so an enterprise identity is scoped, and revoking a connection can revoke its identities).EmailAtLinkis kept for audit/forensics only; the model must never look a user up by it for authentication.
User exposes IReadOnlyCollection<ExternalIdentity> ExternalIdentities and aggregate methods LinkExternalIdentity(...) / UnlinkExternalIdentity(...) that raise domain events (ExternalIdentityLinkedEvent, ExternalIdentityUnlinkedEvent) into the existing Audit pipeline.
2.3 New: IdentityProviderConnection (tenant-scoped aggregate)
One record per enterprise SSO connection. TenantScopedAggregateRoot, soft-delete
- audit like every other entity.
csharp
public sealed class IdentityProviderConnection : TenantScopedAggregateRoot
{
public string DisplayName { get; private set; }
public FederationProtocol Protocol { get; private set; } // Oidc | Saml
public ConnectionStatus Status { get; private set; } // Draft | Active | Disabled
// ── OIDC ──
public string? Authority { get; private set; } // discovery base (…/.well-known/openid-configuration)
public string? ClientId { get; private set; }
public string? ClientSecretEncrypted { get; private set; } // protected via ISecretProtector, bound to tenant+connection
public string[] Scopes { get; private set; }
// ── SAML ──
public string? IdpEntityId { get; private set; }
public string? IdpMetadataUrl { get; private set; } // preferred: auto-refresh certs
public byte[]? IdpSigningCertificate { get; private set; } // pinned; or resolved from metadata
public bool WantAssertionsSigned { get; private set; }
public bool WantAssertionsEncrypted { get; private set; }
// ── policy ──
public ClaimMapping ClaimMappings { get; private set; } // provider claim → Gryd claim
public bool JitProvisioningEnabled { get; private set; }
public Guid? DefaultRoleId { get; private set; }
public bool EnforceSso { get; private set; } // block password+social for this tenant's domains
public bool TrustIdpMfa { get; private set; } = true; // IdP's MFA satisfies tenant MFA policy — no Gryd re-challenge (decision 2026-07-18)
}SP-side identifiers (SP EntityID, ACS URL, redirect URI) are derived from the connection id + a configured base URL, so each connection gets a distinct callback path — which also serves the OAuth mix-up defense (RFC 9207) by giving each IdP its own redirect endpoint.
2.4 New: TenantDomain (verified email domain)
csharp
public sealed class TenantDomain : TenantScopedAggregateRoot
{
public string Domain { get; private set; } // acme.com
public Guid? ConnectionId { get; private set; } // which IdP this domain routes to
public DomainVerificationStatus Status { get; private set; } // Pending | Verified
public string VerificationToken { get; private set; } // DNS TXT challenge
}Home-realm discovery and any cross-tenant auto-linking route only on Verified domains. An unverified domain claim never influences routing or linking — this closes domain-spoofing as an attack on tenant selection.
2.5 Migration
A single EF migration: create ExternalIdentities, IdentityProviderConnections, TenantDomains; drop Users.Auth0UserId, Users.IsSocialUser. Any existing "social" rows (there should be none in production, given the placeholder) are migrated into ExternalIdentities or dropped. Coordinated wire-contract change with @gryd-ui/core (v5 no-back-compat precedent).
3. The connector seam
csharp
public interface IExternalIdentityProvider
{
ExternalProviderType ProviderType { get; }
// Build the redirect to the IdP; persists {state, nonce, pkce, connectionId,
// returnUrl} in a short-TTL cache keyed by state.
Task<ChallengeResult> CreateChallengeAsync(FederationContext ctx, CancellationToken ct);
// Validate the IdP response and normalize. Throws FederationException on any
// validation failure (fail-secure). Never returns a partially-trusted principal.
Task<ExternalPrincipal> HandleCallbackAsync(FederationCallback cb, CancellationToken ct);
}ExternalPrincipal is the only type the application pipeline sees — the provider/protocol details never leak past the seam:
csharp
public sealed record ExternalPrincipal(
ExternalProviderType ProviderType,
string Issuer,
string Subject,
string? Email,
bool EmailVerified,
string? DisplayName,
Guid? ConnectionId,
IReadOnlyDictionary<string, string> Claims);Native implementations shipped:
OidcExternalProvider— generic, config-driven, onMicrosoft.AspNetCore.Authentication.OpenIdConnect. Serves Google, Microsoft, and any enterprise OIDC IdP. Per-tenant options resolved dynamically at request time (§6).AppleExternalProvider— OIDC plus Apple's quirks: ES256 client-secret minting/rotation, first-auth name capture. Built onAspNet.Security.OAuth.Applerather than hand-rolled.SamlExternalProvider— SAML 2.0 SP via Sustainsys.Saml2 (Phase 3).
Optional adapter packages implement the same seam so a deployment can swap in a broker without touching application code — mirroring the existing GrydAuth.Infrastructure.<provider> sub-package convention:
GrydAuth.Federation.WorkOs— one connection to WorkOS; it fans out SAML+SCIM.GrydAuth.Federation.Keycloak— brokered OIDC to a self-hosted Keycloak.
This is the ADR 0004 hedge made concrete: native by default, broker by option.
3.1 Extensibility: adding a new issuer (Open/Closed, DRY)
The seam exists so that adding a provider never touches the pipeline, the linking rules, the domain model, or token issuance. Those are closed for modification; providers are open for extension. Concretely, to add — say — GitHub later:
- If the provider is OIDC-compliant (Google, Microsoft, most enterprise IdPs): adding it is pure configuration — a new connection descriptor (authority, client id/secret, scopes, claim map). Zero new code. They all run through the one
OidcExternalProvider. This is the DRY payoff: three "social buttons" and every enterprise OIDC tenant share a single implementation. - If the provider is OAuth2-only (GitHub is OAuth2, not full OIDC — it returns no
id_token; identity comes from its/userAPI): add a thinOAuth2ExternalProviderimplementation (or a smallGitHubExternalProvider) that performs the code exchange + userinfo call and maps the result toExternalPrincipal.issuer = "https://github.com",subject =the immutable numeric account id. No change to linking / tenant / token code. - SAML enterprise IdPs:
SamlExternalProvider(already planned).
Mechanics that keep this clean:
ExternalProviderTypegrows by one enum value; a provider registry maps type → implementation (strategy pattern) at a single registration point — noswitchon provider scattered through the code.- The security invariants live in the closed core, not in each provider: any new provider automatically inherits PKCE/state/nonce (OIDC), the verified-email and
(issuer, subject)linking rules (§5), audit events, and rate limits. You cannot add a provider that quietly bypasses them — which is the whole point of concentrating them behind the seam. - Each provider declares its trust properties (does it verify email? is the subject immutable? OAuth2 vs OIDC), and the central
AccountLinkerreads those to apply the right rules. GitHub, like Microsoft, can expose unverified or multiple emails → it is flagged "do not auto-link on email," and the existing rules handle it with no special-casing at the call site.
So the answer to "does it make sense to design for future issuers?" is yes, and it is already the load-bearing design goal — the abstraction is chosen precisely so GitHub (or LinkedIn, Facebook, an OIDC IdP we have not heard of yet) is a config entry or a ~one-file provider, never a change to the authentication flow.
4. Authentication flow (SP-initiated, backend-driven)
- Start.
GET /auth/federation/{connectionKey}/start?returnUrl=…(connectionKey= a social provider name for global, or a tenant connection id/slug for enterprise). The provider buildsstate(CSRF, single-use, bound to the browser session),nonce(ID-token replay binding), and a PKCEcode_verifier/code_challenge(S256). These are stored server-side in a short-TTL cache keyed bystate. Redirect to the IdP's authorization endpoint. - IdP authenticates the user (with the IdP's own MFA / conditional access).
- Callback. IdP redirects to the connection's distinct ACS/redirect URI (
/auth/federation/{connectionKey}/callback). The provider:- validates
stateagainst the cache (reject unknown/expired/replayed); - OIDC: exchanges
codeserver-side (confidential client) with the PKCEcode_verifier; validates the ID token — signature via JWKS (honorkid, tolerate rotation),issas a function (Entratid),aud== client_id,azpwhen present,exp/iat/nbf(small skew),noncematches; pins expectedalg, rejectsalg:none; validates the RFC 9207issauthorization-response parameter against the initiated IdP (mix-up defense); - SAML: validates the assertion signature against the pinned IdP cert (from metadata, not from the message); enforces
AudienceRestriction== SP EntityID,Recipient/Destination== ACS URL,InResponseTo== the outstandingAuthnRequestid,NotBefore/NotOnOrAfter; checks a replay cache keyed on assertion id; processes only the signed element (XSW-safe) with exclusive C14N; supportsEncryptedAssertion. - normalizes to
ExternalPrincipal.
- validates
- Link / provision (
AccountLinker, §5). - Resolve tenant. Enterprise: the connection's tenant. Social: verified-domain HRD →
preferredTenantId→ user's default tenant → none (global token). - MFA gate — trust the IdP's MFA (decision 2026-07-18). Federation trusts the IdP's authentication, and by default also trusts the IdP's MFA: the enterprise IdP (Entra/Okta/SAML) enforces its own MFA and conditional access, so a successful federated login satisfies the tenant MFA policy and Gryd does not re-challenge. This is the per-connection
TrustIdpMfaflag, default true. Escape hatch: a tenant that wants Gryd MFA layered on top setsTrustIdpMfa = false, and Gryd issues anmfa_pendingtoken and challenges via the existingMfaChallengeInitiator. Social-vs-enterprise nuance: for an enterprise connection this is unambiguously right — the customer owns and enforces MFA, and re-prompting would fight their conditional access. For a social button (e.g. a personal-style Google login) the provider may not have performed MFA at all; "trust IdP MFA" there means "accept whatever the provider did, possibly none." Because social providers are framework-owned and deployment-global — there is no per-connection row — the social posture lives in deployment config (GrydAuth:Federation:SocialMfa): distrust social MFA outright (TrustIdpMfa = false), or keep trusting it but only on proof (RequireProofOfMfa, matching the IdP'samr/acragainst the accepted methods/context classes). Enterprise connections carry their ownTrustIdpMfaon the connection row. The single decision is resolved byIIdpMfaTrustEvaluatorand flows into the shared MFA gate asSmartSessionRequest.IdpMfaSatisfiesPolicy. - Issue tokens through the existing pipeline —
TokenGenerationRequest→JwtTokenService→RefreshTokenService, honoring the global/tenant/ first-login token model,token_version, the HttpOnly refresh cookie, and Zero Trust scoring. Federation adds no new session mechanism.
@gryd-ui/core change is minimal: the SPA calls /start and handles the final redirect back with the standard token/refresh contract it already consumes.
5. Account linking — the security core
Identity is resolved by (Issuer, Subject), never by email. On each callback:
lookup ExternalIdentity by (Issuer, Subject)
├─ FOUND ─────────────► that user. Update LastUsedAt. Log in.
└─ NOT FOUND ─► decide how to attach:
1. Active session? (user is logged in and explicitly linking a provider)
└─ link to the current user, LinkMethod = ExplicitByUser. ← safest path
2. Enterprise connection with a VERIFIED domain matching the email, and
JIT enabled?
└─ provision/attach within that tenant, LinkMethod = Jit.
3. Email matches an existing account?
└─ auto-link ONLY if ALL of:
• EmailVerified == true, AND
• provider is a trusted verifier (Google workspace/Apple/enterprise —
NOT a bare Microsoft `email` claim), AND
• the existing account has no local password
(no silent takeover of a password account)
otherwise ─► require a verification step (email challenge or
re-auth) before merging. Default = DO NOT implicitly merge.
4. Otherwise ─► JIT-provision a NEW user keyed on (Issuer, Subject),
LinkMethod = Jit, EmailAtLink = email.Hard rules (each maps to a real 2025–2026 attack):
- Never link or authorize on the Microsoft/Entra
emailclaim. Key onoid+tid; validateissas a function oftid; authorizetidagainst the tenant's onboarded-connection allowlist. (nOAuth.) - No implicit merge into a password account. A first social/enterprise login whose email collides with a local password user requires explicit, authenticated linking. (Account pre-hijacking.)
- Auto-link across a tenant boundary only on a verified domain. (Domain spoofing / tenant confusion.)
EmailVerified == false⇒ email is inert for any linking/routing decision.
6. Per-tenant dynamic configuration
Each tenant's OIDC/SAML settings are resolved at request time from the IdentityProviderConnection store, not registered at startup. Pattern (Duende "dynamic authentication providers" style, without buying IdentityServer):
- A single resolver scheme reads
connectionKey→ loads the connection → hydratesOpenIdConnectOptions/ SAML options throughIOptionsMonitor<>+ a keyedPostConfigure, cached with invalidation on connection change (reuseCachePolicies+ the event-handler cache-busting the module already does for User/Role/Tenant events). - OIDC signing keys (JWKS) and SAML IdP certs are refreshed on a schedule from the discovery/metadata URL, so IdP key/cert rotation never causes an outage.
- Client secrets and SAML private keys are stored encrypted at rest by the module's shared secret-protection subsystem (
ISecretProtector), excluded from audit output. The ciphertext is bound to the owning tenant and connection, so a secret copied into another tenant's row fails to decrypt rather than working.GrydAuth:Security:SecretProtectionis a prerequisite for enterprise SSO — and for the host booting at all; see secret-protection.md, including the rotation runbook.
7. Provider-specific handling
Google (OIDC). Discovery-based; trust email only when email_verified; optionally use the hd claim to gate/route Workspace domains.
Microsoft / Entra (OIDC). Use the v2.0 organizations endpoint — work/school accounts only (decision 2026-07-18). Do not use common, and reject personal Microsoft accounts (the MSA tenant tid = 9188040d-6c67-4c5b-b112-36a304b66dad). Validate iss as a function of tid and authorize tid against the onboarded-tenant allowlist — a static issuer comparison rejects every token and is the most common Entra bug. Identity = oid+tid, never the email claim (nOAuth).
Apple. Client secret is a self-minted ES256 JWT (kid header; iss=Team ID, sub=Services ID, aud=https://appleid.apple.com, ≤6-month exp) — minted programmatically and cached with a short TTL, never a static config value. Name/email arrive only on the first authorization (in the form POST, not the ID token) — persist immediately on first callback. Key on sub; handle the private-relay email; honor Apple's token revocation endpoint. Ship Apple if any adopter app is on iOS and offers other social logins (App Store Guideline 4.8).
8. Security control checklist (becomes test coverage)
OIDC/OAuth: PKCE S256 mandatory · state single-use + session-bound · nonce validated · exact redirect-URI match · RFC 9207 iss (mix-up) · full ID-token validation + alg pinning + reject alg:none · JWKS auto-rotation · confidential client, server-side code exchange.
SAML: signature enforced vs pinned metadata cert · audience/recipient/ destination · InResponseTo binding · NotBefore/NotOnOrAfter · replay cache · XSW-safe (process only the signed element) · exclusive C14N · prefer SP-initiated (guard IdP-initiated with replay cache + CSRF) · optional encrypted assertions · metadata auto-refresh.
Cross-cutting: client secrets & SAML keys encrypted at rest · federation-specific rate-limit policies (federation-start, federation-callback) added to the existing set · domain events into Audit (ExternalIdentityLinked, ExternalLoginSucceeded/Failed, ConnectionCreated/Disabled) · Zero Trust risk scoring still applies at token issuance · contract tests enforcing the above, in the spirit of TokenTypeAllowlistContractTests.
9. Multi-tenancy & lifecycle
- Home-realm discovery: unified login collects email → verified-domain lookup → route to the tenant connection; fallback org-picker and per-tenant vanity login (
{tenant}.app/login). - Users in multiple tenants: identity (User + linked externals) is global; membership is per
UserTenant; the login entry point (which connection) determines tenant context; multi-tenant users get the existing 2-min global token for tenant selection. - Enforced SSO: once a tenant admin enables it, password + social login are blocked for that tenant's verified domains; provide a break-glass admin path. Critical for enterprise buyers (ex-employees lose access when the IdP deprovisions).
- Deprovisioning gap (disclosed): JIT provisions but cannot deprovision. Until SCIM (Phase 3), an enterprise that removes a user in their IdP relies on
token_version/session expiry + enforced-SSO to cut access on next token refresh, not instantaneous revocation. State this in security questionnaires. The window is bounded — not eliminated — by §9.1.
9.1 Federated session lifetime — bounding the deprovisioning window
Two limits apply to a session that began at an identity provider. They exist because they bound different populations, and only the pair closes the gap:
| Limit | Bounds | Default | Per connection |
|---|---|---|---|
RefreshTokenLifetime | the idle session — how long one refresh token stays usable | 12 h | yes (null = inherit) |
MaxSessionLifetime | the active session — absolute ceiling from the federated authentication | 24 h | yes (null = inherit) |
A shortened refresh lifetime alone catches nobody who keeps working: every renewal mints a fresh token and restarts that clock. The ceiling is what catches the active user, because renewal cannot push past it. Reaching it forces a new authorization code flow — a real trip back to the IdP, which is where the deprovisioning is known. Gryd never stores the IdP's refresh token (§8), so there is no silent server-to-server renewal that could bypass the trip.
The number to quote in a security questionnaire is the ceiling. With the defaults: a user removed at the identity provider loses access to Gryd within 24 hours of their last SSO login, and sooner if their session goes idle for 12 hours. A tenant that needs a tighter guarantee sets its own values on the connection; a deployment moves the defaults under GrydAuth:Federation:Session.
How it holds together:
- The session carries its origin. The federated login stamps every token it issues with the connection key and the instant the human authenticated. That instant is copied unchanged through every rotation — refreshing it would reset the ceiling on each renewal and the ceiling would never arrive.
- Every issued lifetime is clamped to what remains of the ceiling, so a federated refresh token can never outlive it even if the explicit checks above it were removed.
- Every re-issue path preserves the stamp — rotation, switch-tenant, and the session issued after an MFA challenge. A path that dropped it would return a session indistinguishable from a native one, which renews without limit; that is the bypass the design is arranged to prevent.
- Refusal is distinguishable. A refresh past the ceiling answers
FEDERATION_SESSION_EXPIRED, not a generic expiry, so the client restarts SSO instead of showing a password form the tenant's enforced-SSO may not permit. - Social logins are exempt by default (
ApplyToSocialLogins). A personal Google account has no deprovisioning relationship with the tenant, so a ceiling there would log those users out daily and buy nothing. Deployments that treat social login as workforce identity opt in. - Fail-secure at the edges: a connection deleted or disabled under a live session keeps the deployment default ceiling — revoking a connection must never be the fastest way to grant its sessions an unlimited life.
10. Build vs buy — decision detail
| Option | Default? | Lock-in | Distribution fit (NuGet) | Enterprise (SAML+SCIM) | Owns SAML risk |
|---|---|---|---|---|---|
| Native RP + Sustainsys (recommended) | ✅ | none | self-contained ✅ | SAML native; SCIM = Phase 3 | you |
| WorkOS adapter | option | medium | hosted dep ❌ as default | best (SAML+SCIM+portal) | WorkOS |
| Keycloak adapter | option | low | infra dep ❌ as default | strong | Keycloak |
| Auth0 / Entra External ID / Cognito | option | med–high | hosted dep | high | vendor |
Recommendation: native OIDC + native SAML (Sustainsys.Saml2) behind the seam, with WorkOS and Keycloak adapters available per deployment. The three sign-off decisions this raises are worked through in Appendix A. In short:
- SAML library licensing is not a blocker (corrected from an earlier draft): Sustainsys.Saml2 is MIT and ITfoxtec.Identity.Saml2 is BSD-3-Clause — both permissive and safe to redistribute in NuGet. The residual is choosing and maintaining the library (a security dependency), not a license negotiation. → Appendix A.2.
- Enterprise SCIM demand. If day-one deals require SCIM + directory sync, the WorkOS adapter may be the pragmatic enterprise path while native OIDC keeps social in-house. The seam makes this a per-deployment switch. → Appendix A.3.
11. Delivery phases
Order signed off 2026-07-18: OIDC first, SCIM next, SAML last.
| Phase | Deliverable | Notes |
|---|---|---|
| 0 | ExternalIdentity model, drop Auth0UserId/IsSocialUser, migration, delete Auth0 proxy + accessToken command (Appendix B) | Do first — hard-to-reverse schema. |
| 1 | OIDC social: Google, Microsoft (work/school only), Apple; IExternalIdentityProvider seam; §8 controls; secure account linking | Highest user value, lowest risk. |
| 2 | Enterprise OIDC: per-tenant connections, verified TenantDomain + HRD, JIT, enforced-SSO, TrustIdpMfa, admin API | Unlocks B2B OIDC deals (Entra, Okta, Workspace, Ping over OIDC). |
| 3 | SCIM provisioning: real deprovisioning + directory/group sync | Closes the JIT deprovisioning gap for enterprise OIDC. Whether this ships with Phase 2 or right after it = the one open item (§12). |
| 4 — final | SAML 2.0 SP (Sustainsys.Saml2, MIT) | Moved to the end per 2026-07-18. Demand-driven — build when a deal needs it (ADFS / SAML-only IdPs); not licensing-gated. |
| + | Passkeys/WebAuthn as a complement for non-SSO tenants + step-up | Parallelizable enhancement, off the critical path. |
12. Decisions log & remaining open item
Signed off 2026-07-18:
- Native-by-default, with WorkOS/Keycloak as optional adapters behind the seam. ✅ (Appendix A.1)
- OIDC first (social, then enterprise); SAML is the final phase, demand-driven — not licensing-gated (Sustainsys.Saml2 is MIT). ✅ (§11)
- Trust the IdP's MFA. A successful federated login satisfies the tenant MFA policy; Gryd does not re-challenge. Per-connection
TrustIdpMfa, default true; set false to force Gryd MFA on top (relevant mainly for the social path). ✅ (§4 step 6) - Microsoft = work/school only —
organizationsendpoint, personal MSAs rejected. ✅ (§7)
Still open (being decided with the owner):
- SCIM timing. Does the first enterprise cohort launch on JIT-only (Phase 2) with SCIM as the immediate next phase (Phase 3), or must SCIM ship together with the enterprise launch? This turns on who the first enterprise customers are and whether they contractually require directory-sync deprovisioning. Full trade-off in Appendix A.3; working assumption in §11 is JIT-first, SCIM as Phase 3.
Appendix A — Decision briefs for sign-off
Three decisions need your call before we commit engineering. Each is written to be self-contained: what is being decided, the options, the costs on each side, a recommendation, and the specific condition that would flip it.
A.1 — Native-by-default (with broker adapters) vs. WorkOS as the enterprise default
What is being decided. Not whether WorkOS can be used — it always can, it sits behind the same IExternalIdentityProvider seam. What you are choosing is the out-of-the-box default for enterprise SSO, and therefore where we spend engineering and what dependency every adopter of GrydAuth inherits by default.
Option A — Native by default (recommended). GrydAuth ships the OIDC and SAML RP in-process. An adopter gets social + enterprise SSO with nothing but GrydAuth, their database, and Redis. No third-party runtime, no per-connection fee, no user identity or login traffic leaving their deployment.
- Cost to us: we build and maintain it. OIDC is cheap — the first-party ASP.NET Core handler does the heavy lifting. SAML is the real work: correct assertion validation, the XSW/replay/cert-rotation hardening in §8, an admin UX for connection setup, and the long tail of per-IdP quirks (every customer's Okta/ADFS differs slightly). Brokers exist precisely because that tail is tedious.
- Time to market: social in Phase 1, enterprise OIDC in Phase 2, SAML in Phase 3 — not "in days."
Option B — WorkOS as the enterprise default. Every adopter who wants enterprise SSO integrates once with WorkOS; WorkOS normalizes every IdP, ships a self-serve admin portal for customers to configure their own IdP, and includes SCIM. Enterprise-ready in days.
- Cost, and who pays it: recurring, and it lands on the adopter, not on us. WorkOS runs on the order of US$125 per connection per month (tiered down toward ~$50 at volume; verify current pricing), where one "connection" ≈ one enterprise customer's IdP. So each adopter's enterprise customers become a per-seat SaaS bill. For a framework whose pitch is "own your auth stack," wiring a mandatory SaaS into the enterprise path changes the value proposition.
- Dependency & data: identity records and login traffic transit WorkOS (US-hosted) — a diligence item for LGPD/data-residency-sensitive adopters.
- Lock-in: moderate — connections are re-created on exit; not catastrophic, but real.
Reconciling them. Because both live behind the seam, this is not irreversible and not exclusive. A specific deployment can flip to the WorkOS adapter regardless of the default. So the honest framing: native-by-default spends our engineering to keep the product sovereign and free-to-run; WorkOS-by-default spends adopters' money and adds a dependency to buy immediate enterprise + SCIM.
Recommendation: native by default, WorkOS adapter shipped early. It matches GrydAuth's positioning (self-contained, low lock-in, security owned in-house) and doesn't tax every adopter with a recurring third-party bill. Ship the WorkOS adapter early enough that a deal needing enterprise SSO this quarter can use it while native Phase 2/3 land.
What would flip it: if near-term revenue is concentrated in enterprise deals that need SAML and SCIM within weeks, default the enterprise path to WorkOS (native social only) and trade recurring cost for time-to-revenue.
A.2 — SAML library choice + license for redistribution
What is being decided. Which library implements the SAML SP, and whether its license is acceptable to bundle in a framework redistributed as NuGet.
Correction to the earlier draft (verified against the actual license files). The licensing risk flagged in the first pass is largely a non-issue. Both leading .NET SAML SP libraries are permissively licensed:
| Library | License | Redistribution in NuGet | Notes |
|---|---|---|---|
| Sustainsys.Saml2 | MIT | free, notice only; no copyleft, no commercial restriction | Sustainsys the company sells support/services — the code is MIT. That is the origin of the "dual-license" confusion. |
| ITfoxtec.Identity.Saml2 | BSD-3-Clause | free, notice only | The "change" was the company rename ITfoxtec → FoxIDs; FoxIDs is a separate hosted-IdP product, not a license change on the library. |
Both MIT and BSD-3-Clause let us redistribute inside GrydAuth with no obligation propagating to adopters beyond carrying the notice. Licensing does not gate Phase 3. The decision reduces to a technical/maintenance one:
- Sustainsys.Saml2 (recommended): the most widely used SAML SP for ASP.NET Core; integrates as an authentication handler, which fits our dynamic per-tenant scheme model (§6); multi-IdP support; mature XSW/replay hardening; most-used ⇒ best-vetted against the SAML attack classes in §8. MIT.
- ITfoxtec.Identity.Saml2 (fallback): also solid, strong SAML-P support, good docs, BSD-3. Its maintainer runs FoxIDs (a hosted IdP), so there is a mild "maintainer's incentive points at the SaaS" consideration, but the library is active.
Recommendation: Sustainsys.Saml2 (MIT). Permissive license, best fit for our per-tenant handler pattern, and the widest install base for a security-sensitive dependency. Pin the version and track its CVEs like any other security dependency. Phase 3 is gated only on there being real SAML demand, not on licensing.
What would flip it: Sustainsys maintenance stalling (→ ITfoxtec/BSD-3 as fallback), or a decision not to own SAML security at all (→ route SAML through the WorkOS/Keycloak adapter).
A.3 — SCIM timing: is JIT-only acceptable for the first enterprise cohort?
What is being decided. Whether the first enterprise release ships with just-in-time provisioning only (create/update the user on login) or also with SCIM 2.0 (real-time directory sync, including deprovisioning).
What JIT gives you. On a successful SSO, the user is created (if new) or updated from the assertion's claims; the role can be defaulted or mapped from group claims at that moment. Zero extra integration for the customer.
What JIT cannot do — the gap.
- No deprovisioning. If the customer disables or fires an employee in their IdP, JIT never hears about it. That user keeps whatever access their existing Gryd tokens/sessions grant until those expire.
- No between-login sync. Group/role/attribute changes in the IdP only reflect on the user's next login.
How much the gap actually hurts, with compensating controls.
- Enforced-SSO (block password/social for the tenant's verified domains) makes the IdP the only door. Combined with short access tokens,
token_version, and the refresh dependency, a deprovisioned employee loses access when their current session ends — the exposure window is the refresh-token lifetime, not indefinite. You can shrink it further for SSO users (shorter refresh TTL, or re-validate the IdP session on refresh). - Residual real gap: a just-fired employee holding a live refresh token retains access until it expires, and there is no instantaneous "kill now from the IdP." That is exactly what SCIM closes.
What SCIM adds. The IdP pushes create / update / deactivate / delete and group membership to Gryd in real time. It is the specific control enterprise security questionnaires ask about ("how fast do you deprovision after we disable a user?").
Who requires it. Large or regulated buyers (finance, health, gov) frequently make SCIM contractual. SMB and much of mid-market accept JIT + enforced-SSO to start, provided the deprovisioning behavior is disclosed and SCIM is on the roadmap.
Recommendation: JIT-only is acceptable for the first cohort, but only if (a) enforced-SSO ships alongside it, (b) the SSO refresh-token lifetime is tightened (or refresh re-validates the IdP session), and (c) the deprovisioning latency is disclosed honestly in the security docs with a committed SCIM date. Ship SCIM as the immediate next phase (Phase 3). Do not ship enterprise SSO with neither SCIM nor enforced-SSO — then the deprovisioning answer is "never," which fails diligence.
What would flip it (to SCIM-first): if the first enterprise targets are regulated or name SCIM as a contract condition, pull SCIM into the enterprise release rather than the fast-follow.
Appendix B — Removal inventory: retiring the existing social / Auth0 path
Phase 0 does not merely stop using the old code — it deletes it, so no dead code, no misleading second auth path, and no [ExcludeFromMediatRScan] reflection tricks survive. The inventory below was taken from the current tree (develop). It is grouped by the kind of change, because "delete the file" and "this file stays but a branch inside it must change" are different risks.
B.1 — Delete entirely
| Target | Notes |
|---|---|
Project src/Modules/Auth/GrydAuth.Infrastructure.Auth0/ (all: Auth0Service.cs, Auth0Settings.cs, Auth0ServiceRegistration.cs, GrydAuthAuth0AssemblyMarker.cs, .csproj) | The whole broker package. Auth0Service.GetSocialUserInfoAsync is the hardcoded placeholder. |
Remove the project from Gryd.IO.sln | Project decl line 30, GUID {14B2B010-D98A-4248-A311-221A54615D18}, plus its build-config lines. |
Drop Auth0.AuthenticationApi from Directory.Packages.props | The only consumer is the deleted project. |
GrydAuth.Application/Common/Interfaces/IAuth0Service.cs | Superseded by IExternalIdentityProvider. |
GrydAuth.Application/Common/DTOs/Auth0Dtos.cs (Auth0UserInfo) | Superseded by ExternalPrincipal. |
GrydAuth.Application/Features/Authentication/Handlers/SocialLoginCommandHandler.cs | Replaced by the callback handler. |
Tests: AuthControllerSocialLoginTests.cs, SocialLoginCommandHandlerTests.cs, SocialLoginCommandValidatorTests.cs | Whole files. |
B.2 — Edit to remove members (file stays)
| File | Remove |
|---|---|
Features/Authentication/Commands/AuthenticationCommands.cs | SocialLoginCommand |
Features/Authentication/Validators/AuthenticationValidators.cs | SocialLoginCommandValidator |
Common/Interfaces/IAuthenticationOrchestrator.cs | SocialAuthenticateAsync(...) |
GrydAuth.API/Controllers/AuthController.cs | the [HttpPost("social-login")] SocialLogin(...) action |
GrydAuth.API/Contracts/AuthHttpContracts.cs | SocialLoginRequest |
ApplicationServiceRegistration.cs | the Auth0-handler exclusion/removal dance (the reflection that strips [ExcludeFromMediatRScan] handlers) — no longer needed once the handler is gone |
Resources/Messages/AuthMessages.cs | SocialUserCannotComplete, CannotChangeForSocialUsers, SocialUserRestriction, SocialAuthFailed (×2), SocialUserMustUseSocialLogin |
Resources/Messages/ValidationMessages.cs | the SocialLogin class (ProviderRequired/ProviderInvalid/AccessTokenRequired) |
Common/Constants/AuthTracingConstants.cs | Auth0CreateUser, Auth0GetUser (keep the generic Provider tag — reused by the new code) |
Domain/Entities/User.cs | Auth0UserId, IsSocialUser, CreateSocialUser, SetAuth0UserId, CanAuthenticateSocially, and the ctor's auth0UserId/isSocialUser params. Rewrite CanAuthenticateLocally, RequiresPasswordChange, IsPasswordExpired, GetDaysUntilPasswordExpiration to stop referencing IsSocialUser (they derive from PasswordHash presence instead). Superseded by the ExternalIdentity collection (§2). |
Domain/Repositories/IUserRepository.cs + Infrastructure/Repositories/UserRepository.cs | GetByAuth0UserIdAsync → replaced by an external-identity lookup on (Issuer, Subject) |
Domain/DTOs/UserAuthData.cs | Auth0UserId |
Application/Common/DTOs/UserDtos.cs | Auth0UserId |
Application/Common/DTOs/CachedUserData.cs | IsSocialUser (property + the mapping assignment) |
Infrastructure/Data/Configurations/UserConfiguration.cs | the IsSocialUser / Auth0UserId EF property config |
Infrastructure/Extensions/AuthenticationDbContextExtensions.cs | the IsSocialUser = false seed value |
Users handlers (UserDtoMapper.cs, CreateUserCommandHandler.cs, GetUserByEmailQueryHandler.cs, GetUserByIdQueryHandler.cs, UpdateUserCommandHandler.cs) | any Auth0UserId mapping |
B.3 — Rewire (logic must change, not just delete)
These branch on IsSocialUser; under the new model the concept becomes "the user has no local password credential" (i.e. !CanAuthenticateLocally()):
| File | Current branch |
|---|---|
Features/Authentication/Handlers/CompleteFirstLoginCommandHandler.cs | SocialUserCannotComplete — first-login/password completion is simply skipped for password-less accounts |
Features/Authentication/Handlers/ResetPasswordCommandHandler.cs | !CanAuthenticateLocally() && user.IsSocialUser → !CanAuthenticateLocally() |
Features/Users/Handlers/ChangePasswordCommandHandler.cs | CannotChangeForSocialUsers → "user has no local password to change" |
Not social-coupled — keep the feature, edit one string: the CircuitBreaker (Infrastructure/Services/Security/CircuitBreakerService.cs, Features/CircuitBreaker/Handlers/*) references "Auth0" only as a hardcoded entry in a monitored-services list { "Auth0", "Redis", "Database", "ExternalApi" }. The circuit-breaker feature is unrelated to social login — just rename/replace that string with the new federation dependency name (or drop the entry). Do not delete the CircuitBreaker feature.
B.4 — Rewrite tests
UserBuilder.cs (AsSocialUser/CreateSocialUser → WithExternalIdentity), AuthenticationBuilder.cs (social request builders), HandlerTestBase.cs (Auth0 mock), and the domain/handler tests that construct social users: UserFactoryMethodsTests, UserPasswordTests, UserStateTests, ChangePasswordCommandHandlerTests, ResetPasswordCommandHandlerTests, RequestPasswordResetCommandHandlerTests, CompleteFirstLoginCommandHandlerTests.
B.5 — Cautions
- Migrations are immutable history — do not edit them. The existing
Migrations/*_*.csand*.Designer.csfiles legitimately referenceIsSocialUser; that is a historical fact and must stay. Add one new migration that drops theAuth0UserId+IsSocialUsercolumns and creates theExternalIdentities/IdentityProviderConnections/TenantDomainstables.GrydAuthDbContextModelSnapshot.csregenerates from the model. - Do it as one coherent change set (add
ExternalIdentity+ rewire branches + delete old, together in Phase 0) so the tree never sits in a broken half-state. The existing contract tests (TokenTypeAllowlistContractTestsand friends) are the guardrail. - Confirm no live consumer still calls
POST /auth/social-login(notably@gryd-ui/core) before removing the endpoint; coordinate the contract break on the v5 no-backward-compatibility precedent.