Single sign-on and SCIM

An organization can sign its people in through its own identity provider, prove the e-mail domains it owns with a DNS record, require that sign-in, and create and remove its members from the provider with SCIM 2.0. All of it lives in the identity host, Blouppy.Identity (see Identity), and is built on Microsoft's own OpenID Connect handler — no licensed library and no SAML. The design note names Okta, Microsoft Entra ID and Google Workspace as the target providers; the end-to-end suite runs the scenarios against a Keycloak container.

What it does

Administrators find it under Organization → Security, in three cards: verified domains, the SSO connection and SCIM provisioning.

Verified domains

  • An organization claims a domain and publishes a TXT record at _blouppy-challenge.<domain> with the value blouppy-domain-verification=<challenge>, then asks for verification.
  • One organization per domain, exact domain only (no wildcard), at most 50 per organization (SsoDomainRules.MaxDomainsPerOrganization). A pending claim expires after 14 days.
  • Public mail domains are refused, subdomains included — a built-in list of 39 in SsoDomainOptions.DefaultPublicEmailDomains, which Sso:PublicEmailDomains extends.
  • A verified domain makes its accounts managed: the organization's provider speaks for those addresses and no others.

The OIDC connection

One connection per organization: a display name, the issuer, a client id and secret, the scopes (openid email profile by default), the e-mail claim (email), and the default role of new members — Viewer or Member, never Admin. Its status is Draft, Active or Disabled.

  • Test before activating.Test opens /sso/{id}/test in a pop-up; nobody is signed in or created. Activation needs a Succeeded test on the current version of the settings; the other outcomes are NoEmail, EmailOutsideVerifiedDomains, NotTheAdministrator and ProviderError.
  • Finding the provider. On the login page, the e-mail's domain is looked up as it is typed (/sso/discover, 30 requests a minute per client address) and Continue with … is offered. From the app, /bff/login?sso={connectionId} goes through the provider. A provider's dashboard tile points at /sso/{id}/start.
  • Just-in-time provisioning, on by default: the first SSO sign-in creates the account — confirmed, without a personal organization — and the membership, with the connection's default role. An existing account in a verified domain is linked.
  • No Blouppy second factor after an SSO sign-in: multi-factor is the provider's job. A session that began with SSO lasts 24 hours at most before the provider is asked again.

Enforcement and the owner's fallback

Once SSO is required, accounts in a verified domain — members or not — sign in only through the provider; their password, GitHub and Google are refused (kept, not deleted), and their browser sessions are ended. Members with an address outside the verified domains keep their own sign-in and are marked Not covered by SSO on the members page.

SCIM provisioning and groups

  • One base URL for every organization: https://<gateway>/scim/v2. The token says which organization it is.
  • Tokens are blp_scim_ followed by 32 random bytes, shown once and stored as a SHA-256 hash. At most two are live, so one can be rotated without a gap.
  • Users become accounts and memberships. Deactivating (active: false) or deleting a user removes the membership; for a managed account it also deactivates the account — browser sessions ended, personal access tokens and MCP connectors revoked — without erasing it. The owner cannot be deactivated through SCIM (409).
  • Groups are stored as the provider sends them. A workspace team can be linked to one: its members then follow the group, and its roster is read-only until unlinked.

Which plan

SSO and SCIM are on Business, behind one entitlement, AllowsSso (BillingCatalogOptions). Verifying a domain is on every plan. A plan that loses AllowsSso lifts enforcement at the next sign-in, stops the SCIM tokens at once, and keeps the connection as an optional way in. See Billing.

How it works

The identity host is the federation point and the directory: domains, connections, sign-in routing, linking, the SCIM endpoint and account deactivation. What a membership means — members, roles, teams — stays in the API's Organizations and Workspaces domains, reached through integration events.

PieceWhere
Verified domainsServices/SsoDomains/ (ISsoDomainService), SsoDomainsController
The connectionServices/SsoConnections/ (ISsoConnectionService, ISsoSignInService), SsoConnectionsController
Schemes at runtimeAuthentications/Sso/ — SsoSchemeProvider, SsoOpenIdConnectOptions, SsoCallbackMiddleware, SsoManagedSessionValidator
SCIMControllers/Scim/ (Users, Groups, ServiceProviderConfig, ResourceTypes, Schemas), Services/Scim/, Authentications/Scim/ScimTokenAuthenticationHandler
DNSPort IDnsTxtResolver (Blouppy.Infrastructure.Dns.Abstractions), adapter DnsClientTxtResolver (Blouppy.Infrastructure.Dns, on DnsClient.NET), plugged by the identity host alone
The gatewaySecurity/SsoLoginExtensions.cs (UseSsoLogin), Scim/ScimRoute.cs

A scheme per connection, added at runtime

GitHub and Google are handlers registered at startup. An organization's connection is configured any day, so SsoSchemeProvider decorates the registered IAuthenticationSchemeProvider and answers sso-{connectionId} with a scheme of OpenIdConnectHandler, built from the connection when it is asked for:

var connection = await lookup.GetAsync(connectionId);

if (connection is null)
{
    return null;
}

// The options are cached by name for the process's life: a newer version of the connection rebuilds them.
if (versions.Swap(name, connection.Version) != connection.Version)
{
    optionsCache.TryRemove(name);
}

return new AuthenticationScheme(name, connection.DisplayName, typeof(OpenIdConnectHandler));

GetAllSchemesAsync never lists a connection, so the login page's buttons stay GitHub and Google. ISsoConnectionLookup caches a connection for one minute per replica. SsoCallbackMiddleware hands /sso/{id}/signin-oidc to the connection's handler, since ASP.NET Core only asks the schemes registered at startup about their callbacks. An issuer must be HTTPS, and Entra ID's multi-tenant common, organizations and consumers issuers are refused; discovery and the code exchange go through PublicOnlyHttpHandler, the outbound guard against internal addresses.

Sign-in from the app

APIOrganization's providerIdentity hostGatewayBrowserAPIOrganization's providerIdentity hostGatewayBrowser/bff/login?sso={connectionId}redirect to /sso/{id}/start/sso/{id}/startOpenID Connect challenge (scheme sso-{id})/sso/{id}/signin-oidce-mail in a verified domain? link or create the accountSsoMemberProvisionedIntegrationEventsigned in, back through /bff/login
APIOrganization's providerIdentity hostGatewayBrowserAPIOrganization's providerIdentity hostGatewayBrowser/bff/login?sso={connectionId}redirect to /sso/{id}/start/sso/{id}/startOpenID Connect challenge (scheme sso-{id})/sso/{id}/signin-oidce-mail in a verified domain? link or create the accountSsoMemberProvisionedIntegrationEventsigned in, back through /bff/login

The events

Published by the identity host through its outbox; contracts in Blouppy.Infrastructure.Messaging.Abstractions/Sso/ (see Messaging).

EventConsumers in the API
SsoMemberProvisionedIntegrationEventOrganizations adds the member with the carried role, or accepts a pending invitation
SsoMemberDeprovisionedIntegrationEventOrganizations removes the membership; Workspaces hands the person's workspaces to the organization's owner
DirectoryGroupChangedIntegrationEventOrganizations keeps organization_directory_groups; Workspaces resynchronises the linked teams

The administration endpoints are the identity host's, under api/identity/organizations/{organizationId}/sso/ (domains, connection, scim), reached through the gateway's /identity route and guarded by Policies.OrganizationAdministration. That policy reads the API's sso_organizations_view on every request, so a role taken away in the app stops working there at once. A team is linked with PUT / DELETE /api/v1/workspaces/{workspaceId}/teams/{id}/directory-group.

The SCIM endpoint

The gateway forwards /scim/v2 to the identity host as a plain YARP route: the bearer is kept, the cookie stripped. ScimTokenAuthenticationHandler (scheme ScimToken, policy Policies.Scim) refuses a token whose organization lost AllowsSso. Requests are rate-limited to 600 a minute per token, answered 429 with Retry-After. Filters are a single attr eq "value"; there is no bulk, no sort and no /Me. Payloads are application/scim+json.

Configuration

Set on the identity host.

SettingDefaultWhat it does
Sso__PublicEmailDomainsemptyExtra domains nobody may claim, added to the built-in list, never replacing it
Dns__Resolvers1.1.1.1, 8.8.8.8The resolvers TXT lookups ask, never the container's own
Dns__Timeout3 secondsPer lookup; a timeout is 503 ssoDomain.dnsUnavailable, not "record not found"
Sso__Connections__AllowInsecureIssuer, …__AllowPrivateNetworkIssuerfalseAn HTTP issuer, or one on a private address — for a local provider. Forced off outside Development

The SCIM base URL and the connection's redirect and start URIs are shown on the Security page. In production the identity host must reach the resolvers on port 53, UDP and TCP.

Extending it

What was deliberately left out (sso-scim.md §13), each a piece of work of its own:

  • SAML.
  • Requiring multi-factor from the provider (amr containing mfa).
  • Re-checking a verified domain after verification.
  • A session cap per organization; the 24-hour cap is the same for everyone.
  • An e-mail address changed through SCIM — refused with 400 mutability.
  • Directory groups mapped to roles; they map to teams only, and the role of a new member is the connection's default.
  • An Only members from our domains setting.
  • Disabling the connection 30 days after a downgrade.
  • Erasing a deactivated account automatically; erasure stays the person's request.

Limits

  • Sign-out is local: Blouppy does not sign the person out of their provider.
  • The SSO administration is not in the audit log yet: domains, the connection and SCIM tokens are NotAudited rows of the identity host's AuditCoverage. What the provider does to members reaches the audit log through the API.
  • No provider-initiated flow: OIDC has none, so a tile starts an ordinary sign-in through /sso/{id}/start.