toolup-forgetoolup-forge

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:

  1. Signature (HS256 with signingKey).
  2. exp claim — not expired (with clockSkewSeconds tolerance).
  3. iss claim matches expectedIssuer (if set).
  4. aud claim matches expectedAudience (if set).

Identity projection:

  • sub claim → UserId.
  • name claim → DisplayName.
  • email claim → 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.AcceptedAlgorithmsNone 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. Use StaticJwtAuthProvider for 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:

  1. Redirect to {Issuer}/authorize with PKCE challenge.
  2. User authenticates at the issuer.
  3. Issuer redirects back to {RedirectUri} with auth code.
  4. Client exchanges code for tokens (with PKCE verifier).
  5. 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:

  1. Fetches the issuer's JWKS via OIDC discovery ({Issuer}/.well-known/openid-configurationjwks_uri). Cached in sessionStorage with 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.
  2. Verifies the RS256 signature against the JWK matching the JWT header's kid via WebCrypto (crypto.subtle.verify). Pure browser-native; no npm deps.
  3. Validates iss equals OidcUIConfig.Issuer, aud contains OidcUIConfig.ClientId, and exp is in the future (60s clock-skew tolerance — mirrors the server-side default).
  4. 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:

  1. Issuer URL construction. Built from a tenant parameter (plus an optional customDomain override) as https://<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.
  2. Claim mapping. Projects oid -> AuthenticatedUser.UserId (more stable than sub in External ID; sub varies per app registration, oid is constant per user per tenant) and tid -> AuthenticatedUser.TenantId. The federated-IdP claim (idp) is readable via EntraExternalIdAuthProvider.readIdpClaim for audit decorators that want per-IdP attribution.
  3. User-flow policies. Optional signUpPolicyId / signInPolicyId route 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_access is 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_access scope set (the OIDC defaults omit offline_access; External ID requires it for refresh-token issuance).
  • Optional "Sign up" affordance routed through the configured sign-up policy when SignUpPolicyId is set.
  • ValidateIdToken = Some true — client-side id_token validation (signature + iss + aud + exp via WebCrypto) runs on every callback. The generic OidcUIConfig.defaults leaves this None for 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 via ValidateIdToken = Some false on the projected OidcUIConfig (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:

  1. Create an External ID tenant. Entra portal -> External Identities -> Create external tenant. Note the tenant name (the short form, not the GUID).
  2. Register the app. External Identities -> Applications -> New registration. Single-page application; redirect URI matches the RedirectUri you wire client-side.
  3. Enable ID + access tokens in the app registration's Authentication blade.
  4. 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.
  5. Federated identity providers (optional) — Identity providers -> add Google / Apple / Facebook / Microsoft consumer accounts. The federated provider's identifier surfaces as the idp claim on issued tokens.
  6. API permissions. Application -> API permissions -> add at least openid / profile / email / offline_access (Microsoft Graph delegated).
  7. Claim mapping. Under the user-flow blade, ensure oid, tid, email, and idp are 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:

  • StaticJwtAuthProvider with Clerk's signing key.
  • Or a custom IAuthProvider that 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 by ScopeResolutionMiddleware.
  • PlatformRoleMember (default) or PlatformAdmin (deployment-wide admin).
  • TeamRoleOwner / 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 with TOOLUP_TRUST_FORWARDED_HEADERS=0 only on a direct-bind dev shell).
  • ServerConfig.SecurityHeaders = StrictSecurityHeaders.
  • ServerConfig.CorsConfig — explicit allow-list for browser callers.
  • TOOLUP_INITIAL_PLATFORM_ADMIN set for the bootstrap admin user.
  • OIDC issuer trusted at the network layer (TLS pin if possible).
  • IConfigValidator preflight runs at boot; refuses to start on Error outcomes.