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 valueblouppy-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, whichSso:PublicEmailDomainsextends. - 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}/testin a pop-up; nobody is signed in or created. Activation needs aSucceededtest on the current version of the settings; the other outcomes areNoEmail,EmailOutsideVerifiedDomains,NotTheAdministratorandProviderError. - 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.
| Piece | Where |
|---|---|
| Verified domains | Services/SsoDomains/ (ISsoDomainService), SsoDomainsController |
| The connection | Services/SsoConnections/ (ISsoConnectionService, ISsoSignInService), SsoConnectionsController |
| Schemes at runtime | Authentications/Sso/ — SsoSchemeProvider, SsoOpenIdConnectOptions, SsoCallbackMiddleware, SsoManagedSessionValidator |
| SCIM | Controllers/Scim/ (Users, Groups, ServiceProviderConfig, ResourceTypes, Schemas), Services/Scim/, Authentications/Scim/ScimTokenAuthenticationHandler |
| DNS | Port IDnsTxtResolver (Blouppy.Infrastructure.Dns.Abstractions), adapter DnsClientTxtResolver (Blouppy.Infrastructure.Dns, on DnsClient.NET), plugged by the identity host alone |
| The gateway | Security/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
The events
Published by the identity host through its outbox; contracts in
Blouppy.Infrastructure.Messaging.Abstractions/Sso/ (see Messaging).
| Event | Consumers in the API |
|---|---|
SsoMemberProvisionedIntegrationEvent | Organizations adds the member with the carried role, or accepts a pending invitation |
SsoMemberDeprovisionedIntegrationEvent | Organizations removes the membership; Workspaces hands the person's workspaces to the organization's owner |
DirectoryGroupChangedIntegrationEvent | Organizations 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.
| Setting | Default | What it does |
|---|---|---|
Sso__PublicEmailDomains | empty | Extra domains nobody may claim, added to the built-in list, never replacing it |
Dns__Resolvers | 1.1.1.1, 8.8.8.8 | The resolvers TXT lookups ask, never the container's own |
Dns__Timeout | 3 seconds | Per lookup; a timeout is 503 ssoDomain.dnsUnavailable, not "record not found" |
Sso__Connections__AllowInsecureIssuer, …__AllowPrivateNetworkIssuer | false | An 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 (
amrcontainingmfa). - 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
NotAuditedrows of the identity host'sAuditCoverage. 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.