Auth provider companions
Auth provider companions
The Platform's IAuthProvider interface is identity-only — given an HTTP request, return the authenticated user. Provider companions translate from specific authentication mechanisms (OIDC, Clerk, static JWT, custom) to the SDK's AuthenticatedUser record.
This page is a cross-cutting overview of the shipped provider companions. For full details on the IAuthProvider contract + how authentication interacts with the SDK's authorisation model, see platform/auth.md.
What's shipped
| Companion | Side | Description |
|---|---|---|
HeaderAuthProvider (built into ToolUp.Platform.Server) |
Server | Trusts X-User-Id HTTP header. Dev-only. |
StaticJwtAuthProvider (built into ToolUp.Platform.Server) |
Server | Validates HS256 JWTs. BCL-only, no external NuGet deps. |
ToolUp.AuthProviders.Oidc |
Server | Generic OIDC server-side validator. JWKS discovery + JWS signature verification (RS256 by default; opt in to RS384 / RS512 / ES256 / PS256 via AuthConfig.AcceptedAlgorithms). |
ToolUp.AuthProviders.Oidc.Client |
Client | OIDC sign-in UI: Authorization Code + PKCE flow. |
ToolUp.AuthProviders.EntraExternalId |
Server | Microsoft Entra External ID (CIAM): wraps Oidc with tenant-aware issuer construction + oid/tid claim mapping. |
ToolUp.AuthProviders.EntraExternalId.Client |
Client | Entra External ID sign-in UI: wraps OidcClient with offline_access scope default + sign-up / sign-in user-flow policy routing. |
ToolUp.AuthProviders.ClerkUI |
Client | Clerk sign-in UI; commercial product integration. |
Client- vs server-side: the OIDC stack ships as two packages because authentication has both ends. The server validates tokens; the client renders the sign-in UX. They share no code but share the OIDC protocol.
Picking a provider
HeaderAuthProvider (dev / local-only)
Use when:
- Local development; no real auth needed.
- Testing CI/CD where the test user identity is injected via the test harness.
Don't use when:
- Anything reachable from the internet. The header is trivially spoofable.
Setup:
// Default — no withAuth call needed; HeaderAuthProvider is the implicit default
ServerApp.empty
|> ServerApp.withConfig config
|> ...
|> ServerApp.run
The HeaderAuthProviderModeValidator IConfigValidator emits a Warning at preflight when running in authenticated mode without withAuth — flags the misconfiguration.
StaticJwtAuthProvider (in-house JWT issuance)
Use when:
- You issue JWTs in-house (your own auth service generates them).
- HS256 is acceptable (symmetric signing key).
- You don't need JWKS discovery.
Setup:
let authProvider =
StaticJwtAuthProvider(
signingKey = "your-symmetric-signing-key",
expectedIssuer = Some "https://your-issuer.example.com",
expectedAudience = Some "your-app-id",
clockSkewSeconds = 60
) :> IAuthProvider
ServerApp.empty
|> ServerApp.withAuth authProvider
|> ...
Token validation:
- Signature (HS256 with
signingKey). expclaim — not expired (withclockSkewSecondstolerance).issclaim matchesexpectedIssuer(if set).audclaim matchesexpectedAudience(if set).
Identity projection:
subclaim →UserId.nameclaim →DisplayName.emailclaim →Email.
ToolUp.AuthProviders.Oidc (production OIDC)
Use when:
- You have an OIDC provider — Auth0, Cognito, Keycloak, Azure AD, Google Workspace, etc.
- Tokens are signed with one of the algorithms in the whitelist (RS256 by default; see Supported JWS algorithms).
- You want JWKS auto-discovery (signing keys fetched from the provider; rotation handled).
Setup:
open ToolUp.AuthProviders.Oidc
let authProvider =
OidcAuthProvider(
issuer = "https://your-issuer.example.com",
audience = "your-client-id"
) :> IAuthProvider
ServerApp.empty
|> ServerApp.withAuth authProvider
|> ...
Configuration via environment variables (read by the provider at startup):
TOOLUP_OIDC_ISSUER— required.TOOLUP_OIDC_AUDIENCE— required.TOOLUP_OIDC_CLOCK_SKEW_SECONDS— optional; default 60.
OidcAuthValidator IConfigValidator probes .well-known/openid-configuration at preflight; refuses startup if unreachable. ServerConfig.SkipPreflight = true bypasses.
Pair with ToolUp.AuthProviders.Oidc.Client for the browser-side sign-in flow.
Wiring IMetricsSink (Phase 9e.A)
When the deployment registers a real IMetricsSink (default-shipped Prometheus sink under MetricsEndpoint = EnabledMetricsEndpoint, or the OtelMetricsSink companion), construct the auth provider via the metered overloads so the auth pipeline emits toolup.auth.validate.* counters tagged provider=oidc (or provider=entra-external-id) alongside the SDK's other observability metrics:
open ToolUp.Platform.Metrics
open ToolUp.AuthProviders
// Resolve the sink from the SDK's DI container after `compose` registers it.
let metrics: IMetricsSink option =
services.GetService<IMetricsSink>() |> Option.ofObj
// Production shorthand:
let auth = OidcAuthProvider.fromConfigMetered (Some logger) metrics authConfig
// Or, for the env-driven dispatcher:
let auth =
AuthProvider.fromEnvMetered
logger
metrics
ToolUp.AuthProviders.OidcAuthProvider.fromConfigMetered
ServerApp.empty
|> ServerApp.withAuth auth
|> ...
The non-metered constructors (fromConfig / fromConfigWith / AuthProvider.fromEnv) remain unchanged and elide emission. Each provider instance binds its own sink in its closure — there is no module-level mutable state. See docs/migrations/9e-A-auth-metrics-di.md for the full migration shape (Entra mirrors this pattern via createMetered / fromEnvMetered).
Supported JWS algorithms
The provider's signature-verification step dispatches on the JWT header's alg field. The deployment-trusted set is controlled by AuthConfig.AcceptedAlgorithms — None resolves to [ RS256 ] (the historical default; every existing consumer is byte-for-byte unchanged). Operators opt in to additional algorithms explicitly:
let authConfig = {
Issuer = Some issuerUrl
Audience = Some audience
KeySource = JwksDiscovery issuerUrl
TokenLocation = BearerHeader
ClockSkewSeconds = None
AcceptedAlgorithms = Some [ RS256; ES256 ] // ← e.g. Cognito-shape interop
}
alg |
Crypto | JWK shape | Typical issuers |
|---|---|---|---|
RS256 |
RSA + SHA-256 + PKCS#1 v1.5 | { "kty": "RSA", "n": "...", "e": "AQAB" } |
OIDC ecosystem default — Entra / Azure AD / Auth0 / Okta / Keycloak / Google / Clerk |
RS384 |
RSA + SHA-384 + PKCS#1 v1.5 | Same as RS256 (key shape is hash-agnostic) | Hardened RSA deployments wanting a stronger hash |
RS512 |
RSA + SHA-512 + PKCS#1 v1.5 | Same as RS256 | Rarely issued in practice; supported for completeness |
ES256 |
ECDSA over P-256 + SHA-256 (IEEE-P1363 signature transport, not DER) | { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } |
AWS Cognito (some configurations), Firebase Auth federated paths, dynamic-client OIDC flows |
PS256 |
RSA + SHA-256 + PSS padding | Same RSA key shape as RS256 (PSS is a signature-side choice) | Sites that ban PKCS#1 v1.5 by policy |
Rejected by design:
HS256(and other symmetric MAC variants) — OIDC's trust model forbids sharing symmetric secrets with browser-side parties. UseStaticJwtAuthProviderfor in-house HS256 issuance instead.EdDSA/Ed25519— deferred until customer demand surfaces.
An inbound token whose alg is not in the configured AcceptedAlgorithms is rejected with UnsupportedAlgorithm "<name>" even if its signature would verify against the JWKS — the trust set is operator-owned, not auto-widened by the SDK. See docs/migrations/3-A-oidc-algorithm-whitelist.md for the security rationale + the per-IdP opt-in matrix.
ToolUp.AuthProviders.Oidc.Client (browser sign-in)
Browser-side counterpart to the OIDC server provider. Implements OAuth 2.0 Authorization Code + PKCE.
Setup:
open ToolUp.AuthProviders.Oidc.OidcClient
open ToolUp.AuthProviders.Oidc.OidcRegister
OidcRegister.register
{ Issuer = "https://your-issuer.example.com"
ClientId = "your-client-id"
RedirectUri = "https://your-app.example.com/callback"
Scope = "openid profile email" }
Client.run
{ ClientConfig.defaults with
AppName = "MyApp"
Mode = Individual
AuthUI = ConfiguredAuthUI OidcClient.uiProvider }
modules
The sign-in button in the app's header invokes the OIDC flow:
- Redirect to
{Issuer}/authorizewith PKCE challenge. - User authenticates at the issuer.
- Issuer redirects back to
{RedirectUri}with auth code. - Client exchanges code for tokens (with PKCE verifier).
- Bearer token persists in
localStorage; sent on every API request.
Token refresh: the client checks exp on the access token; when within 5 minutes of expiry, calls {Issuer}/token with the refresh token. No manual intervention.
Client-side id_token validation (Phase 3b.A — opt-in)
By default, the callback handler binds the returned id_token to this sign-in attempt via nonce validation (mandatory; on by default since Cluster B1), then trusts the id_token's signature / iss / aud / exp until the server validates them on the next protected request. Opt in to immediate client-side validation by setting OidcUIConfig.ValidateIdToken = Some true:
let oidcConfig = {
OidcUIConfig.defaults issuer clientId redirectUri with
ValidateIdToken = Some true
}
With this enabled, after the nonce check the callback handler:
- Fetches the issuer's JWKS via OIDC discovery (
{Issuer}/.well-known/openid-configuration→jwks_uri). Cached insessionStoragewith a 10-minute TTL — short enough to follow a key rotation, long enough that a multi-page session doesn't refetch on every navigation. Mirrors the server-side cache TTL. - Verifies the RS256 signature against the JWK matching the JWT header's
kidvia WebCrypto (crypto.subtle.verify). Pure browser-native; no npm deps. - Validates
issequalsOidcUIConfig.Issuer,audcontainsOidcUIConfig.ClientId, andexpis in the future (60s clock-skew tolerance — mirrors the server-side default). - On any failure: clears local state and returns a typed
AuthError(MalformedIdToken/IdTokenSignatureInvalid/IdTokenIssuerInvalid/IdTokenAudienceInvalid/IdTokenExpired).
The pipeline is defence-in-depth — the server's OidcAuthProvider.ValidateRequest is the authoritative gate, but failing fast at the callback shortens the time between "issuer issued a bad token" and "user sees a clear error." ValidateIdToken defaults to None (off) in the 0.3.x line; the default flips to true in a coordinated minor bump once consumers have adopted. Algorithm dispatch currently supports RS256 only (the universal OIDC default); wider algorithm support (ES256 / RS384 / RS512 / PS256) lands as an additive follow-on alongside the server-side algorithm-list expansion in Phase 3.A.
See migration: 3b-A-oidc-id-token-validation for the consumer-side rollout.
ToolUp.AuthProviders.EntraExternalId (Microsoft Entra External ID)
Opinionated wrapper around ToolUp.AuthProviders.Oidc for the Microsoft Entra External ID identity service (the customer-facing CIAM tier — distinct from workforce Entra ID / Azure AD). Bakes in three pieces of External-ID-specific knowledge that are easy to get wrong with raw OIDC config:
- Issuer URL construction. Built from a
tenantparameter (plus an optionalcustomDomainoverride) ashttps://<tenant>.ciamlogin.com/<tenant>/v2.0. Always v2.0 — the v1.0 endpoint exists but rejects the bound audience format used by current app registrations. - Claim mapping. Projects
oid->AuthenticatedUser.UserId(more stable thansubin External ID;subvaries per app registration,oidis constant per user per tenant) andtid->AuthenticatedUser.TenantId. The federated-IdP claim (idp) is readable viaEntraExternalIdAuthProvider.readIdpClaimfor audit decorators that want per-IdP attribution. - User-flow policies. Optional
signUpPolicyId/signInPolicyIdroute the corresponding affordances through External ID's policy endpoints (oauth2/v2.0/authorize?p=<policyId>); absent, the default authorize endpoint is used.
Use when:
- Targeting Entra External ID for customer-facing sign-in.
- You want refresh-token issuance (
offline_accessis in the default scope set; the generic OIDC defaults omit it).
Setup:
open ToolUp.AuthProviders
// Inline:
let config = {
Tenant = "contoso"
CustomDomain = None
Audience = "5e2c1f...client-id"
ClockSkewSeconds = None
SignUpPolicyId = Some "B2C_SignUp"
SignInPolicyId = None
}
let authProvider = EntraExternalIdAuthProvider.create None config
// Or from env vars (see below):
let authProvider =
EntraExternalIdAuthProvider.fromEnv None
|> Option.defaultWith (fun () -> failwith "TOOLUP_ENTRA_EXTERNAL_ID_TENANT not set")
ServerApp.empty
|> ServerApp.withAuth authProvider
|> ...
Configuration via environment variables:
TOOLUP_ENTRA_EXTERNAL_ID_TENANT— required.TOOLUP_ENTRA_EXTERNAL_ID_AUDIENCE— required.TOOLUP_ENTRA_EXTERNAL_ID_CUSTOM_DOMAIN— optional.TOOLUP_ENTRA_EXTERNAL_ID_SIGN_UP_POLICY— optional.TOOLUP_ENTRA_EXTERNAL_ID_SIGN_IN_POLICY— optional.TOOLUP_ENTRA_EXTERNAL_ID_CLOCK_SKEW_SECONDS— optional; default 60.
EntraExternalIdAuthValidator IConfigValidator probes <issuer>/.well-known/openid-configuration at preflight; refuses startup if unreachable. ServerConfig.SkipPreflight = true bypasses (same posture as OidcAuthValidator).
The generic OIDC pair remains independently usable — consumers wanting raw OIDC for non-Entra providers don't import this companion.
Pair with ToolUp.AuthProviders.EntraExternalId.Client for the browser-side sign-in flow.
ToolUp.AuthProviders.EntraExternalId.Client (Entra External ID sign-in UI)
Browser-side counterpart to the External ID server provider. Wraps ToolUp.AuthProviders.Oidc.Client with Entra-aware defaults:
openid profile email offline_accessscope set (the OIDC defaults omitoffline_access; External ID requires it for refresh-token issuance).- Optional "Sign up" affordance routed through the configured sign-up policy when
SignUpPolicyIdis set. ValidateIdToken = Some true— client-side id_token validation (signature + iss + aud + exp via WebCrypto) runs on every callback. The genericOidcUIConfig.defaultsleaves thisNonefor back-compat with pre-3b.A consumers; Entra is a customer-facing CIAM surface where defence-in-depth at the boundary is worth the small cost of the WebCrypto verify per sign-in. Opt out viaValidateIdToken = Some falseon the projectedOidcUIConfig(regression investigation, intentional fallback during a JWKS-fetch outage).
Wired via the SDK's CustomAuthUI extension point (no edit to AuthUIMode required):
open ToolUp.AuthProviders.EntraExternalId
let entraConfig =
EntraExternalIdClientConfig.create
"<tenant>" // External ID tenant
"<client-id>" // App-registration client id
"https://app.example.com/auth/callback"
Client.run
{ ClientConfig.defaults with
AppName = "MyApp"
Mode = MultiTeam
AuthUI = CustomAuthUI { Wrap = EntraExternalIdAuthUI.wrap entraConfig } }
modules
Setup walkthrough — Entra portal:
- Create an External ID tenant. Entra portal -> External Identities -> Create external tenant. Note the tenant name (the short form, not the GUID).
- Register the app. External Identities -> Applications -> New registration. Single-page application; redirect URI matches the
RedirectUriyou wire client-side. - Enable ID + access tokens in the app registration's Authentication blade.
- User-flow policies. Identity providers -> User flows -> create separate sign-up and sign-in flows if you want them split. Note the policy id for
SignUpPolicyId/SignInPolicyId. - Federated identity providers (optional) — Identity providers -> add Google / Apple / Facebook / Microsoft consumer accounts. The federated provider's identifier surfaces as the
idpclaim on issued tokens. - API permissions. Application -> API permissions -> add at least
openid/profile/email/offline_access(Microsoft Graph delegated). - Claim mapping. Under the user-flow blade, ensure
oid,tid,email, andidpare emitted on the issued tokens (External ID emits these by default).
For the full operator playbook including federated-IdP wiring and the invitation flow, see docs/migrations/3d-entra-external-id-invitations.md.
ToolUp.AuthProviders.ClerkUI (Clerk integration)
Wraps Clerk's React components and surfaces them through the AuthUIProvider registry. Clerk is a commercial product — this companion is a thin integration shim, not a Clerk redistribution.
Setup:
open ToolUp.AuthProviders.ClerkUI
Client.run
{ ClientConfig.defaults with
AppName = "MyApp"
Mode = Individual
AuthUI = ConfiguredAuthUI ClerkUI.uiProvider }
modules
Server-side, validate the bearer token via:
StaticJwtAuthProviderwith Clerk's signing key.- Or a custom
IAuthProviderthat calls Clerk's verification API.
Configuration via environment / Vite env:
CLERK_PUBLISHABLE_KEY— required client-side.- Server-side signing key — see Clerk's docs.
#if DEBUG in the consuming app typically controls Clerk activation — debug skips sign-in, release enables it.
Writing a custom provider
For an auth mechanism not covered by the shipped companions (LDAP / SAML / proprietary / etc.):
module MyAuthProvider
open Microsoft.AspNetCore.Http
open ToolUp.Platform
open ToolUp.Platform.Auth
type MyAuthProvider(config: MyAuthConfig) =
interface IAuthProvider with
member _.GetUser(ctx: RequestContext) = async {
// Phase 11.C.5 Tier 3 — unwrap the opaque `RequestContext`
// to the underlying `HttpContext` at one site per impl.
let httpCtx = RequestContext.value ctx :?> HttpContext
let token = httpCtx.Request.Headers.["Authorization"].ToString().Replace("Bearer ", "")
match validateToken token with
| Ok claims ->
return {
UserId = claims.Subject
DisplayName = claims.Name
Email = claims.Email
TenantId = None
Roles = []
}
| Error _ -> return AuthenticatedUser.anonymous
}
member _.ValidateRequest(ctx: RequestContext) = async {
let httpCtx = RequestContext.value ctx :?> HttpContext
// Cheap pre-checks + validate the bearer token; return
// `Ok user` on success or `Error reason` on failure.
return Error "not implemented"
}
Pair with an AuthUIProvider if you need a client-side sign-in UI. Register via withAuth on ServerApp.
See platform/auth.md for the full authoring guide + production hardening checklist.
How auth interacts with the SDK
The auth provider is identity-only. The SDK adds:
AccessContext— userId + teamId + mode + permissions + platform role. Resolved per-request byScopeResolutionMiddleware.PlatformRole—Member(default) orPlatformAdmin(deployment-wide admin).TeamRole—Owner/Admin/Member. Per-team.ModulePermissions— per-team, per-module.Read | Write | Admin | NoAccess. Empty map = unrestricted.
Module API handlers wrap in makePermissionGuardedApi which checks ModulePermissions before invoking. The auth provider doesn't see permissions; the SDK does.
SSE has its own auth caveat — EventSource can't send custom headers, so OIDC bearer tokens need an alternative path (query string with short-lived tokens, session cookie, or a pre-handshake POST). See platform/auth.md "SSE auth caveat".
Hardening checklist for production
- Real auth provider (not
HeaderAuthProvider). ServerConfig.RequireHttps = true.ServerConfig.TrustForwardedHeaders = true(the Phase 16d default — opt out withTOOLUP_TRUST_FORWARDED_HEADERS=0only on a direct-bind dev shell).ServerConfig.SecurityHeaders = StrictSecurityHeaders.ServerConfig.CorsConfig— explicit allow-list for browser callers.TOOLUP_INITIAL_PLATFORM_ADMINset for the bootstrap admin user.- OIDC issuer trusted at the network layer (TLS pin if possible).
IConfigValidatorpreflight runs at boot; refuses to start onErroroutcomes.