Authentication
Authentication
The Platform separates authentication (who is the caller?) from authorisation (what can they do?). Auth providers are pluggable; the SDK owns the rest.
IAuthProvider
Identity-only interface:
type IAuthProvider =
abstract GetUser: HttpContext -> Async<AuthenticatedUser option>
abstract ValidateRequest: HttpContext -> Async<Result<unit, AuthError>>
GetUser returns the resolved identity (or None for anonymous requests in Anonymous mode). ValidateRequest runs cheap pre-checks (token signature, expiry, issuer / audience) and returns Error to short-circuit the pipeline.
AuthenticatedUser carries:
UserId: string— stable identity (typically the OIDCsubclaim or equivalent)DisplayName: string optionEmail: string option
That's the entire identity contract. Permissions and team membership are SDK concerns layered on top via IPermissionStore and ITeamStore; the auth provider doesn't know about them.
Shipped providers
HeaderAuthProvider (dev default)
Trusts X-User-Id HTTP header verbatim. No validation, no signature, no expiry. Safe only for local dev and tightly-network-gated demo deployments.
let authProvider = HeaderAuthProvider() :> IAuthProvider
StaticJwtAuthProvider
HS256 JWT validation. BCL-only (no external NuGet deps). Checks signature, expiry, optional issuer / audience. Extracts sub, name, email claims.
Suitable when JWT issuance is in-house and rotation is operationally managed. Not a real OIDC integration — there's no JWKS discovery or RS256 support; for that, use the OIDC companion.
let authProvider =
StaticJwtAuthProvider(
signingKey = "...",
expectedIssuer = Some "https://issuer.example.com",
expectedAudience = Some "my-app"
) :> IAuthProvider
ToolUp.AuthProviders.Oidc (server-side)
Generic OIDC server-side validator. Discovers JWKS via .well-known/openid-configuration, validates RS256 JWT bearer tokens against the discovered keys.
Works against any OIDC-compliant issuer — Auth0, Cognito, Keycloak, etc. Pair with the matching client-side provider (ToolUp.AuthProviders.Oidc.Client) for the Authorization Code + PKCE flow.
let authProvider =
OidcAuthProvider(
issuer = "https://your-issuer.example.com",
audience = "your-client-id"
) :> IAuthProvider
Configuration via environment variables:
TOOLUP_OIDC_ISSUER— required.TOOLUP_OIDC_AUDIENCE— required.TOOLUP_OIDC_CLOCK_SKEW_SECONDS— optional, default 60.
OidcAuthValidator (an IConfigValidator) probes the issuer's .well-known/openid-configuration at preflight and refuses to start if the issuer is unreachable. Set ServerConfig.SkipPreflight = true to bypass.
ToolUp.AuthProviders.Oidc.Client (client-side)
Browser-side OIDC sign-in UI. Implements OAuth 2.0 Authorization Code + PKCE. Registers via the AuthUIProvider delegate registry; deployments select it through ClientConfig.AuthUI.
// Client.fs
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
ToolUp.AuthProviders.EntraExternalId{,.Client} (Microsoft Entra External ID)
Opinionated wrapper around the generic OIDC pair for Microsoft Entra External ID (the customer-facing CIAM tier). The server companion constructs the v2.0 issuer URL from a tenant identifier (plus optional custom domain), applies the oid > sub claim convention (External ID's oid is constant per user per tenant; sub varies per app registration, so mapping sub -> UserId produces a different id every time the consumer adds a second app registration), and projects tid -> TenantId. The federated-IdP claim (idp — google.com / apple.com / live.com / local for the tenant's own user pool) is readable via EntraExternalIdAuthProvider.readIdpClaim for audit decorators that want per-IdP attribution.
The browser companion adds offline_access to the default scope set (External ID requires it for refresh-token issuance) and routes sign-up / sign-in through the configured user-flow policies when supplied.
The SSE auth caveat below applies unchanged to External-ID-issued tokens — the access token still rides in the Authorization header on the standard API path, and the SSE handshake follows whichever cookie/query-string fallback the deployment configured.
See docs/companions/auth-providers.md and the Phase 3d migration docs/migrations/3d-entra-external-id-invitations.md for the full operator playbook.
ToolUp.AuthProviders.ClerkUI (client-side)
Wraps Clerk's React components and surfaces them through the AuthUIProvider registry. The server still validates the bearer token via a separate provider (typically StaticJwtAuthProvider configured against Clerk's signing key, or a custom Clerk-specific impl).
Clerk is a commercial product with its own licence and pricing — this companion is a thin client-side integration shim, not a Clerk redistribution.
Wiring an auth provider
ServerApp.empty
|> ServerApp.withConfig { ServerConfig.defaults with Mode = Individual }
|> ServerApp.withAuth oidcAuthProvider
|> ServerApp.addModules modules
|> ServerApp.run
Omit the withAuth call entirely for HeaderAuthProvider (the default). Authenticated modes (AuthenticatedEphemeral / Individual / Team / MultiTeam) without withAuth will run on the header provider in production — usually a misconfiguration. HeaderAuthProviderModeValidator (an IConfigValidator) emits a Warning if the combination is detected; set ServerConfig.SkipPreflight = true to bypass.
Writing a new auth provider
A new provider lives in src/AuthProviders/<Name>/ with its own .fsproj. Implement IAuthProvider, expose a create function, and (for the client side) register via AuthUIProvider.
module MyAuthProvider
open ToolUp.Platform
type MyAuthProvider(config: MyAuthConfig) =
interface IAuthProvider with
member _.GetUser(ctx) = async {
// Read bearer token, validate, project to AuthenticatedUser
let token = ctx.Request.Headers.["Authorization"].ToString().Replace("Bearer ", "")
match validateToken token with
| Ok claims ->
return Some {
UserId = claims.Subject
DisplayName = claims.Name
Email = claims.Email
}
| Error _ -> return None
}
member _.ValidateRequest(ctx) = async {
// Cheap pre-checks; return Error to short-circuit
return Ok ()
}
Provider rules:
- Never read environment variables directly. Accept config via the
createfunction. - Never log the bearer token, even at trace level. Log a hashed prefix if you must.
- Use
ISecretStorefor any provider-side secret (signing keys, client secrets, etc.) — never hardcode. - Document the precision of clock-skew tolerance in the README.
- Author an
IConfigValidatorto verify the provider is reachable / correctly configured at preflight. - Author an
IHealthCheckfor/readyparticipation.
For a complete example see src/AuthProviders/Oidc/ (server-side) and src/AuthProviders/OidcClient/ (client-side).
SSE auth caveat
Server-Sent Events open a long-lived connection to /api/notifications. The browser's EventSource API does not allow custom request headers, so OIDC bearer tokens can't be sent the usual way. Three options:
- Query string — append
?token=<bearer>to the SSE URL. Server reads from query string. Risk: tokens land in server access logs. Mitigation: short-lived tokens, redacted logs. - Cookie — server sets a session cookie on sign-in; SSE reads it automatically. Risk: CSRF surface. Mitigation:
SameSite=Strict+ CSRF token on state-changing requests. - Pre-handshake — a brief
/api/auth/sse-handshakePOST exchanges the bearer token for a short-lived SSE-scoped opaque session ID;EventSourceconnects with that. Most secure; most plumbing.
SseAuthModeValidator (an IConfigValidator) emits a Warning when authenticated modes are configured without a documented SSE auth strategy. Deployments set ServerConfig.SseAuthMode explicitly to acknowledge the choice.
Permissions + roles
Auth providers don't carry permissions; the SDK does:
PlatformRole— deployment-wide. Today:Member(default) andPlatformAdmin. Bootstrap one admin viaTOOLUP_INITIAL_PLATFORM_ADMIN=<userId>.TeamRole— per-team.Owner,Admin,Member. Set when a user joins a team; managed viaPlatformApi.ChangeMemberRole.ModulePermission— per-team, per-module.Read | Write | Admin | NoAccess. Stored viaIPermissionStore; default empty map = unrestricted.
Module API handlers go through makePermissionGuardedApi which checks the caller's ModulePermissions before invoking the API function. This is the only sanctioned authorisation choke-point. Modules do not check permissions themselves; the wrap is automatic via ServerModule.withGuardedApi.
Platform Admin paths (assigning admins, destroying encryption keys, writing to the Platform KB) gate on PlatformRole.PlatformAdmin. The audit log records every role assignment and revocation.
Audit events emitted
Auth-related events under _platform.audit:
UserLoggedIn— first authenticated request in a session (first-seen-this-session, not per-request).RoleAssigned/RoleRevoked— Platform Admin role changes.TeamMemberAdded/TeamMemberRemoved/TeamMemberRoleChanged— team membership changes.
Every event carries the actor's userId, the affected userId (if different), the resource Id, and a server-side timestamp. The audit-sink replication layer mirrors these to any configured external sinks (Splunk, Datadog, S3 archive).
Hardening checklist for production
ServerConfig.RequireHttps = true— registersapp.UseHttpsRedirection().ServerConfig.TrustForwardedHeaders = true— default-on since Phase 16d. Behind a TLS-terminating proxy this is needed so secure-cookie scoping andUrl.IsAbsoluteUrisee the originating scheme. Direct-bind dev shells with no proxy opt out withTOOLUP_TRUST_FORWARDED_HEADERS=0.ServerConfig.SecurityHeaders = StrictSecurityHeaders— emits CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.ServerConfig.RateLimit— configure a per-team / per-user / per-IP partition with a sensible fixed-window cap./health,/ready, and/api/notificationsare excluded by default.ServerConfig.CorsConfig— explicit allow-list for cross-origin browser callers; reject*for credentialed requests.ServerConfig.MaxRequestBodyBytes— explicit cap. Default is generous; tighten for production.- Real auth provider (not
HeaderAuthProvider). Real OIDC or Clerk or in-house JWT. TOOLUP_INITIAL_PLATFORM_ADMINset for the bootstrap user.- Encryption-at-rest decorator wired with
PerScopeKeyResolver(for crypto-shred capability).
The IConfigValidator preflight runs all of these at boot; missing pieces surface as Warning or Error (the latter refuses to start). The default policy is operator-friendly: most hardening knobs default to off, and the validator nudges you to opt in rather than failing closed.