Table of Contents

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.​OnResolve​External​Provider event. If the event handler selects an upstream provider, Sphinx:

  1. Redirects the browser to the upstream provider's authorization endpoint (using the authorization-code flow with PKCE, state and nonce).
  2. After the user authenticates at the upstream provider, receives the callback at oauth/external/callback, exchanges the code for the upstream tokens and validates the id_token.
  3. Fires the TSphinxConfig.​OnExternal​SignIn event so your application can resolve which local user the upstream identity maps to.
  4. 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.​External​Providers collection. You can add them at design-time, by double-clicking the ExternalProviders property, or from code by calling ExternalProviders.Add, which creates a TSphinxExternal​Provider.

At a minimum, set a unique TSphinxExternal​Provider.​Name (used to reference the provider from code), the TSphinxExternal​Provider.​Authority, and the TSphinxExternal​Provider.​ClientId/TSphinxExternal​Provider.​Client​Secret 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 TSphinxExternal​Provider.​Auto​Discover 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 TSphinxExternal​Provider.​Issuer, TSphinxExternal​Provider.​Authorization​Endpoint and TSphinxExternal​Provider.​Token​Endpoint 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:

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 TSphinxExternal​Provider.​Skip​Issuer​Validation 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.​External​Providers 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.​OnGet​External​Provider event lets you supply providers dynamically, exactly like TSphinxConfig.​OnGet​Client 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 TSphinxExternal​Provider 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.​OnResolve​External​Provider event is the single decision point that turns identity brokering on for a given request. The handler inspects the request through the TResolveExternal​Provider​Args argument (client id, scope, prompt, tenant) and sets TResolveExternal​Provider​Args.​Provider​Name 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 TSphinxExternal​Provider.​Show​InLogin​Page to True. Use TSphinxExternal​Provider.​Display​Name for the button text and the optional TSphinxExternal​Provider.​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

TSphinxExternal​Provider.​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 example https://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
Google img/providers/google.svg
Microsoft img/providers/microsoft.svg
GitHub img/providers/github.svg
Apple img/providers/apple.svg
Facebook 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.​OnList​External​Providers event. The list passed in TListExternal​Providers​Args.​Provider​Names 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.​OnGet​External​Provider 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.​Allow​Password​Login 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.​OnExternal​SignIn 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.​OnExternal​SignIn 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:

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.​External​Login​Options and Sphinx reconciles automatically. The policy runs in this order:

  1. Resolve an existing link by (provider, subject).
  2. If none and TExternalLogin​Options.​Allow​Auto​Link​ByEmail is enabled, match an existing user by e-mail (requiring a verified e-mail when TExternalLogin​Options.​Require​Verified​Email​ForAuto​Link is True, the default) and persist the link.
  3. If still none and TExternalLogin​Options.​Allow​Auto​Provision is enabled, create a new local user from the upstream profile, link it, and fire TSphinxConfig.​OnExternal​User​Provisioned.
  4. 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.​OnExternal​User​Provisioned 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.​Require​Email. So at the time TSphinxConfig.​OnExternal​User​Provisioned fires, fields such as Email may be empty; they are collected from the user at login time (see below). For the same reason, calling IUserManager.​Update​User from this handler raises a validation error while a required field is still missing — only update the user once you have supplied every required identifier yourself, otherwise leave the field to be collected by the complete-profile step.

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.​Require​Email 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.​Require​Confirmed​Email still applies.

Custom mapping with OnExternalSignIn

When you need full control, handle TSphinxConfig.​OnExternal​SignIn. The handler receives a TExternalSignInArgs exposing the upstream provider name, the convenience properties TExternalSign​InArgs.​Subject, TExternalSign​InArgs.​Email and TExternalSign​InArgs.​Email​Verified, the raw TExternalSign​InArgs.​Auth​Result, and a TExternalSign​InArgs.​User​Manager bound to the current request. When a built-in policy is configured, TExternalSign​InArgs.​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 TExternalSign​InArgs.​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 TExternalSign​InArgs.​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 TExternalLogin​Options.​Save​Tokens. 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.​Get​Authentication​Token:

  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 TExternalSign​InArgs.​Skip​TwoFactor property is that control:

  • Leave it False (the default) to honor the user's normal Sphinx two-factor configuration.
  • Set it to True to 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, state and nonce are 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.external cookie that is compared against the upstream state; 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 TSphinxExternal​Provider.​Skip​Issuer​Validation only as a last resort, for providers whose per-tenant issuer cannot be validated any other way.