Identity brokering (external providers)
Sphinx can act as an identity broker (also known as an IdP proxy or identity federation): client applications still talk only to Sphinx as their OpenID Connect provider, but behind the scenes Sphinx delegates the actual authentication to an upstream OAuth2/OpenID Connect server. Sphinx receives the upstream result, lets your application map the upstream identity to a local user, and then completes the original request with its own Sphinx-issued tokens.
This is useful when you want your applications to authenticate against an existing identity provider (a corporate IdP, Google, Microsoft Entra, another Sphinx server, etc.) while still issuing and controlling your own tokens, users, scopes and sessions.
How the flow works
When a client application starts an authorization request and there is no active Sphinx session, Sphinx fires the TSphinxConfig.OnResolveExternalProvider event. If the event handler selects an upstream provider, Sphinx:
- Redirects the browser to the upstream provider's authorization endpoint (using the authorization-code flow with PKCE, state and nonce).
- After the user authenticates at the upstream provider, receives the callback at
oauth/external/callback, exchanges the code for the upstream tokens and validates theid_token. - Fires the TSphinxConfig.OnExternalSignIn event so your application can resolve which local user the upstream identity maps to.
- Establishes the Sphinx session for that user (enforcing two-factor authentication if required) and resumes the original request, redirecting back to the client application with a Sphinx authorization code or token.
If the resolver does not select a provider, Sphinx falls back to its normal interactive login.
Registering an upstream provider
Upstream providers are registered in the TSphinxConfig.ExternalProviders collection. You can add them at design-time, by double-clicking the ExternalProviders property, or from code by calling ExternalProviders.Add, which creates a TSphinxExternalProvider.
At a minimum, set a unique TSphinxExternalProvider.Name (used to reference the provider from code), the TSphinxExternalProvider.Authority, and the TSphinxExternalProvider.ClientId/TSphinxExternalProvider.ClientSecret registered for Sphinx at that provider.
Provider := SphinxConfig1.ExternalProviders.Add;
Provider.Name := 'google';
Provider.DisplayName := 'Google';
Provider.Authority := 'https://accounts.google.com';
Provider.ClientId := 'your-client-id';
Provider.ClientSecret := 'your-client-secret';
Provider.Scope := 'openid email profile';
Provider.AutoDiscover := True;
When TSphinxExternalProvider.AutoDiscover is True (the default), Sphinx fetches the provider's discovery document from Authority + "/.well-known/openid-configuration" to obtain the endpoint URLs. For providers without discovery, set AutoDiscover := False and provide TSphinxExternalProvider.Issuer, TSphinxExternalProvider.AuthorizationEndpoint and TSphinxExternalProvider.TokenEndpoint manually.
The redirect URI Sphinx uses with the upstream provider is its own external callback endpoint, oauth/external/callback (relative to the Sphinx base URL). Register that URL as an allowed redirect URI at the upstream provider.
Sphinx can reach an upstream provider in two ways, which can be combined:
- Transparently, where Sphinx itself decides to delegate a request to a provider (see Choosing when to delegate).
- Interactively, where the end user picks a provider from a "Login with..." button on the login page (see "Login with..." interactive provider selection). Set TSphinxExternalProvider.ShowInLoginPage to
Trueand optionally TSphinxExternalProvider.IconUrl to offer a provider on the login page.
Multi-tenant providers
Some providers are multi-tenant: a single application accepts users from many tenants (organizations), and the iss (issuer) claim in the resulting token differs per tenant. The prime example is Microsoft Entra ("Login with Microsoft") configured with the organizations or common authority, e.g.:
Provider := SphinxConfig1.ExternalProviders.Add;
Provider.Name := 'microsoft';
Provider.DisplayName := 'Microsoft';
Provider.Authority := 'https://login.microsoftonline.com/organizations/v2.0';
Provider.ClientId := '<your-app-client-id>';
Provider.ClientSecret := '<your-app-client-secret>';
Provider.ShowInLoginPage := True;
Provider.IconUrl := 'img/providers/microsoft.svg';
For such providers the discovery document does not return a fixed issuer; it returns a template with a {tenantid} placeholder, for example https://login.microsoftonline.com/{tenantid}/v2.0. Each user's token then carries the concrete issuer for their own tenant, such as https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0.
Sphinx handles this automatically: when the issuer contains a {tenantid} placeholder, it is resolved with the token's tenant id (tid) claim before the issuer is validated. The validation stays strict — the tenant in the issuer must match the token's tid claim — so you should leave TSphinxExternalProvider.SkipIssuerValidation off (its default). Only fall back to disabling validation if you face a non-standard provider that cannot be validated this way.
Dynamic providers
Registering providers statically in the TSphinxConfig.ExternalProviders collection is convenient, but not always possible: you may not know the providers at design time, you may not want to store client secrets in the form, or the provider (or some of its properties) may depend on the tenant. The TSphinxConfig.OnGetExternalProvider event lets you supply providers dynamically, exactly like TSphinxConfig.OnGetClient does for client applications.
Whenever Sphinx needs to resolve a provider by name — to start a transparent or interactive flow, or to complete an upstream callback — it creates a temporary TSphinxExternalProvider and sets its Name. If a provider with that name exists in the collection, its properties are copied into the temporary object and Accept is passed as True; otherwise Accept is False. Your handler fills in (or overrides) any property and sets Accept to True to use the provider or False to reject it.
procedure TForm1.SphinxConfig1GetExternalProvider(Sender: TObject;
Provider: TSphinxExternalProvider; var Accept: Boolean);
begin
if Provider.Name = 'acme-idp' then
begin
Provider.DisplayName := 'ACME SSO';
Provider.Authority := LookupTenantAuthority('acme');
Provider.ClientId := 'sphinx';
Provider.ClientSecret := GetSecretFromVault('acme'); // never stored in the form
Accept := True;
end;
end;
This event is the single source of truth for a provider's properties, regardless of how the provider was reached.
Choosing when to delegate
The TSphinxConfig.OnResolveExternalProvider event is the single decision point that turns identity brokering on for a given request. The handler inspects the request through the TResolveExternalProviderArgs argument (client id, scope, prompt, tenant) and sets TResolveExternalProviderArgs.ProviderName to the name of a registered provider to delegate the request, or leaves it empty to use the normal Sphinx login.
procedure TForm1.SphinxConfig1ResolveExternalProvider(Sender: TObject;
Args: TResolveExternalProviderArgs);
begin
// Transparently delegate every request to the corporate identity provider.
Args.ProviderName := 'corporate-idp';
end;
Because the decision is entirely in your hands, you can delegate only for specific clients, tenants or scopes, and otherwise fall back to local login.
Note
A request with prompt=login is still delegated to the resolved provider, and prompt=login is propagated upstream so the provider reauthenticates the user. A request with prompt=none always short-circuits to a login_required error when there is no session, regardless of the resolver.
"Login with..." interactive provider selection
Transparent brokering decides the provider on the server. Alternatively, you can let the end user choose, the familiar "Login with Google / Microsoft / ..." experience: the Sphinx login page renders a button for each available upstream provider, and clicking one starts the brokered flow for that provider.
To offer a provider on the login page, set its TSphinxExternalProvider.ShowInLoginPage to True. Use TSphinxExternalProvider.DisplayName for the button text and the optional TSphinxExternalProvider.IconUrl for an icon shown next to it.
Provider := SphinxConfig1.ExternalProviders.Add;
Provider.Name := 'google';
Provider.DisplayName := 'Google';
Provider.IconUrl := 'img/providers/google.svg'; // a built-in icon, see below
Provider.ShowInLoginPage := True;
// ... authority, client id/secret, scope as above
This works alongside transparent brokering and is independent from it: a provider used only for transparent delegation simply leaves ShowInLoginPage as False and stays hidden from the login page.
Provider icons
TSphinxExternalProvider.IconUrl accepts either an absolute or a relative URL, and it is used as-is as the src of an <img> element on the button. Because it is a plain image reference, you can point it to a .png, .jpg or .svg file — browsers render SVG natively in <img>, so there is no need for inline SVG markup.
- Absolute URL (starting with
http://,https://,//or/): used verbatim. Use this to host your own icons or point to an external CDN, for examplehttps://your-server/img/google.svg. - Relative URL (anything else): resolved against the login app root, so it is served from wherever the Sphinx login page itself is mounted, no matter the base URL of your server.
For convenience, Sphinx ships ready-to-use, brand-colored icons for the most common providers as part of the login app. Set IconUrl to one of the relative paths below and no extra hosting is required:
| Provider | IconUrl value |
|---|---|
img/providers/google.svg |
|
| Microsoft | img/providers/microsoft.svg |
| GitHub | img/providers/github.svg |
| Apple | img/providers/apple.svg |
img/providers/facebook.svg |
Adjusting the list per request
The set of providers shown on the login page is, by default, every provider with ShowInLoginPage = True. To tailor it per request — for example to offer different providers per tenant, or to add a dynamic provider that is not registered statically — handle the TSphinxConfig.OnListExternalProviders event. The list passed in TListExternalProvidersArgs.ProviderNames is pre-seeded with the flagged providers; add, remove or reorder names as needed.
procedure TForm1.SphinxConfig1ListExternalProviders(Sender: TObject;
Args: TListExternalProvidersArgs);
begin
// Offer the tenant-specific provider only for that tenant.
if Args.TenantId = 'acme' then
Args.ProviderNames.Add('acme-idp');
end;
This event deals only with which provider names appear and in what order. Each provider's display properties (name, icon) and connection settings always come from the provider itself (or from TSphinxConfig.OnGetExternalProvider for dynamic providers), so they are never specified twice.
External-only login
By default the login page shows both the local username/password form and the "Login with..." buttons. To build an external-only login that authenticates exclusively through upstream providers, set TLoginOptions.AllowPasswordLogin to False:
SphinxConfig1.LoginOptions.AllowPasswordLogin := False;
The login page then hides the local form and shows only the provider buttons. As a safeguard against locking everyone out, the local form is still shown when no external providers happen to be available for the request.
How it works
Each button points the browser at the oauth/external/login endpoint with the chosen provider and the current login transaction id. Sphinx starts the upstream authorization-code flow (PKCE, state, nonce) bound to that existing transaction and redirects to the upstream provider. From there the flow is identical to transparent brokering: the upstream result arrives at oauth/external/callback, TSphinxConfig.OnExternalSignIn maps it to a local user, and the original request resumes.
Mapping the upstream identity to a local user
Once the upstream authentication completes, Sphinx must decide which local user the upstream identity corresponds to. There are two complementary ways to do this: the built-in reconciliation policy (configuration only, no code) and the TSphinxConfig.OnExternalSignIn event (full control). They can be combined — the policy runs first and the event, when present, can override its result.
Linking external identities
The durable, recommended key for reconciliation is the pair (provider, subject): the upstream provider name plus the stable sub claim, which — unlike the e-mail address — never changes. Sphinx persists this mapping in a dedicated link table through the TUserLogin entity, and exposes it on the user manager:
- IUserManager.FindByLogin — resolve the local user for an upstream identity (honors the active multitenant filter).
- IUserManager.AddLogin — link an upstream identity to a local user (idempotent).
- IUserManager.RemoveLogin / IUserManager.GetLogins — unlink, or list a user's linked identities.
Matching by e-mail is convenient but fragile (e-mails change, may be unverified, and the same address can come from different providers). Prefer resolving by link, and fall back to e-mail only on the first sign-in to establish the link.
Built-in reconciliation policy
For the common cases you do not need to write any code: configure TSphinxConfig.ExternalLoginOptions and Sphinx reconciles automatically. The policy runs in this order:
- Resolve an existing link by (provider, subject).
- If none and TExternalLoginOptions.AllowAutoLinkByEmail is enabled, match an existing user by e-mail (requiring a verified e-mail when TExternalLoginOptions.RequireVerifiedEmailForAutoLink is
True, the default) and persist the link. - If still none and TExternalLoginOptions.AllowAutoProvision is enabled, create a new local user from the upstream profile, link it, and fire TSphinxConfig.OnExternalUserProvisioned.
- Otherwise the sign-in is rejected with
access_denied.
All options default to off (conservative), so nothing happens automatically until you opt in:
SphinxConfig1.ExternalLoginOptions.AllowAutoLinkByEmail := True; // link to existing accounts by verified e-mail
SphinxConfig1.ExternalLoginOptions.AllowAutoProvision := True; // create new accounts on first sign-in
Use TSphinxConfig.OnExternalUserProvisioned to finish a freshly provisioned account (assign roles, copy profile fields). It fires only when the policy actually creates a user:
procedure TForm1.SphinxConfig1ExternalUserProvisioned(Sender: TObject;
Args: TExternalUserProvisionedArgs);
begin
// The user has already been created and linked; complete the account.
Args.User.UserName := Args.AuthResult.Profile.PreferredUserName;
Args.UserManager.UpdateUser(Args.User);
end;
Note: the provisioned account may not yet have every identifier your application requires — for example, Facebook does not return an e-mail address even when your app sets TUserOptions.RequireEmail. So at the time TSphinxConfig.OnExternalUserProvisioned fires, fields such as
Completing missing required fields
An upstream provider does not always return everything your application requires. The classic case is Facebook, which returns no e-mail (and no phone number) in its profile, even though many applications set TUserOptions.RequireEmail so they can contact the user.
Sphinx handles this without any code on your part. The user is still provisioned (it is identified by its external login), but it cannot obtain a session while a required identifier is missing. Instead, the login app shows a "complete your profile" step that asks the user for the missing field; once supplied, the sign-in continues normally. The collected value is validated like any other (format and uniqueness are enforced) and an e-mail entered here is stored unverified, so TLoginOptions.RequireConfirmedEmail still applies.
Custom mapping with OnExternalSignIn
When you need full control, handle TSphinxConfig.OnExternalSignIn. The handler receives a TExternalSignInArgs exposing the upstream provider name, the convenience properties TExternalSignInArgs.Subject, TExternalSignInArgs.Email and TExternalSignInArgs.EmailVerified, the raw TExternalSignInArgs.AuthResult, and a TExternalSignInArgs.UserManager bound to the current request. When a built-in policy is configured, TExternalSignInArgs.User arrives pre-filled with the user it resolved, which you can accept, replace or reject.
Assign User with the local user to sign in. Leaving it unassigned, or calling TExternalSignInArgs.Reject, aborts the sign-in with an access_denied error.
procedure TForm1.SphinxConfig1ExternalSignIn(Sender: TObject;
Args: TExternalSignInArgs);
var
User: TUser;
begin
// Resolve first by the durable link (provider + subject).
User := Args.UserManager.FindByLogin(Args.ProviderName, Args.Subject);
// First sign-in: match an existing account by e-mail and remember the link from now on.
if User = nil then
begin
if not Args.EmailVerified then
begin
Args.Reject('A verified email is required');
Exit;
end;
User := Args.UserManager.FindByEmail(Args.Email);
if User <> nil then
Args.UserManager.AddLogin(User, Args.ProviderName, Args.Subject);
end;
Args.User := User;
end;
You can also reuse the built-in policy from within the handler by calling TExternalSignInArgs.Resolve, then adjust the outcome — for example to apply extra checks before accepting the policy's result.
Storing upstream tokens
If your application later needs to call the upstream provider's APIs on the user's behalf, enable TExternalLoginOptions.SaveTokens. After a successful sign-in Sphinx persists the upstream access_token, refresh_token and id_token for the user, keyed by the provider name. Read them back with IUserManager.GetAuthenticationToken:
SphinxConfig1.ExternalLoginOptions.SaveTokens := True;
// later, for a signed-in user:
AccessToken := UserManager.GetAuthenticationToken(User, 'google', 'access_token');
Two-factor authentication
By default, an external sign-in respects the local user's normal Sphinx two-factor configuration, exactly like an interactive login: if the user has two-factor enabled (TUser.TwoFactorEnabled, TwoFactorRequired, or the global login option), Sphinx still challenges for the second factor before completing the sign-in.
However, when authentication is delegated to an upstream provider, the upstream is usually the authentication authority — and it may have already performed multi-factor authentication. Whether to additionally enforce Sphinx's own second factor is a per-provider decision that only you can make, because you know how your chosen provider behaves. The TExternalSignInArgs.SkipTwoFactor property is that control:
- Leave it
False(the default) to honor the user's normal Sphinx two-factor configuration. - Set it to
Trueto trust the upstream's authentication and skip Sphinx's own challenge.
A common pattern is to inspect the upstream amr (authentication methods, RFC 8176) or acr claims — available through Args.AuthResult.Profile.Source (the raw id_token claims) or Args.AuthResult.IdToken — to confirm the provider already performed MFA, and skip the local challenge accordingly:
procedure TForm1.SphinxConfig1ExternalSignIn(Sender: TObject;
Args: TExternalSignInArgs);
begin
Args.User := ResolveLocalUser(Args);
// The upstream already performed multi-factor authentication (inspect the amr/acr claims in
// Args.AuthResult): trust it and do not challenge again.
if UpstreamPerformedMfa(Args.AuthResult) then
Args.SkipTwoFactor := True;
end;
Note
When the local two-factor challenge is required, the callback redirects the browser to the Sphinx login application to resolve the second factor, just like the password login flow. The login application then either prompts for the authenticator code (when the user already has two-factor enabled) or guides the user through authenticator setup, showing a QR code (when two-factor is required but not yet configured). Once completed, the original request resumes and the client receives its Sphinx authorization code or token.
Trying it in the demo
The Simple demo server (demos/Simple/Server) ships a ready-to-fill template in
ConfigureExternalProviders (MainForm.pas). Register the demo's callback URL,
http://localhost:2001/tms/sphinx/oauth/external/callback, as an authorized redirect URI in the
Google Cloud Console, then drop in your credentials:
Provider := SphinxConfig1.ExternalProviders.Add;
Provider.Name := 'google';
Provider.DisplayName := 'Google';
Provider.Authority := 'https://accounts.google.com';
Provider.ClientId := '<your-google-client-id>';
Provider.ClientSecret := '<your-google-client-secret>';
Provider.ShowInLoginPage := True;
A "Continue with Google" button then appears on the login page. The demo's OnExternalSignIn handler
resolves the user by its link first, then matches by e-mail and persists a TUserLogin
link (provisioning a local user on first sign-in), so the brokered flow works end to end.
Security notes
- PKCE,
stateandnonceare generated by Sphinx for the upstream request and validated when the callback is processed. - The callback is bound to the originating browser through a single-use
sphinx.externalcookie that is compared against the upstreamstate; a mismatch is rejected. - The external authorization state is single-use and subject to idle and absolute expiry.
- Client secrets configured for upstream providers are kept on the server only.
- Issuer validation is on by default and should be kept on. For multi-tenant providers whose issuer is a
{tenantid}template (see Multi-tenant providers), Sphinx resolves the placeholder with each token's tenant id before validating, so there is no need to disable validation. Enable TSphinxExternalProvider.SkipIssuerValidation only as a last resort, for providers whose per-tenant issuer cannot be validated any other way.