Migration — 0.3.x OIDC provider presets (advisory)
Migration — 0.3.x OIDC provider presets (advisory)
Status. Additive on 0.3.x. Existing OidcUIConfig.defaults issuer clientId redirectUri constructors continue to work; nothing forces consumers to adopt presets at this version line. The unified OidcAppConfig record (one declaration for both server and client) lands at the coordinated 0.X.0 minor bump alongside Phase 66 — this advisory document narrows the diff consumers will apply at that point by getting the provider-specific quirks codified now.
What changed
A new ToolUp.AuthProviders.Oidc.OidcPresets module ships in ToolUp.AuthProviders.Oidc.Client 0.3.x. Each preset is a smart constructor that takes the consumer-relevant inputs and returns (OidcUIConfig * PresetMetadata): the config the existing OidcAuthUI consumes plus a provenance record the (forthcoming) coherence validator reads.
| Preset | Inputs | Auto-applies |
|---|---|---|
OidcPresets.generic |
issuer + clientId + redirectUri |
No provider quirks — scope set is the OIDC-spec minimum (openid profile email). For Okta, Keycloak, custom OIDC. |
OidcPresets.entraWorkforce |
tenantId + clientId + redirectUri |
api://{clientId}/access_as_user scope (load-bearing); workforce v2.0 issuer URL; offline_access for refresh tokens. |
OidcPresets.entraExternalId |
tenantSubdomain + clientId + redirectUri |
CIAM *.ciamlogin.com v2.0 issuer; offline_access; ValidateIdToken = Some true (defence-in-depth at the customer-facing boundary). |
OidcPresets.entraExternalIdWithDomain |
tenantSubdomain + customDomain + clientId + redirectUri |
Same as entraExternalId with a custom CIAM domain replacing the *.ciamlogin.com host. |
OidcPresets.auth0 |
domain + clientId + redirectUri |
Tenant URL with trailing slash; offline_access for refresh tokens. (Auth0 tokens stay opaque unless an audience extra param is passed.) |
Why presets — workforce Entra worked example
The motivating defect is the workforce-Entra deployment pattern. The known-correct config involves four moving parts that a consumer trying to wire workforce Entra for the first time has no in-code signal for:
- Issuer URL:
https://login.microsoftonline.com/{tenantGuid}/v2.0— MUST be a GUID (or"common"for multi-tenant), not a tenant domain. - App registration manifest setting
requestedAccessTokenVersion: 2— without this, Entra returns v1 tokens the SDK's validators reject. - The
api://{clientId}/access_as_userscope on the authorize request — load-bearing. Without it, Entra issues an opaque access token addressed to Microsoft Graph; the application server's audience validation rejects every request after sign-in. The user lands signed-in-but-401-storm with no clear UX signal what's wrong. - Standard OIDC scopes +
offline_accessfor refresh-token rotation.
Until presets shipped, consumers hand-coded all four — typically alongside a comment essay explaining why #3 is load-bearing. A consumer authoring the config from scratch typically gets #3 wrong on the first attempt and only discovers the issue when sign-in succeeds but every authenticated request 401s.
Worked migration — workforce Entra consumer
A consumer wiring workforce Entra today looks roughly like this:
// CONSUMER `Client.fs` — pre-preset shape (typical):
let entraTenantId = __ENTRA_TENANT_ID__ // Vite-injected build-time
let entraClientId = __ENTRA_CLIENT_ID__
let oidcCfg: OidcUIConfig = {
Issuer = sprintf "https://login.microsoftonline.com/%s/v2.0" entraTenantId
ClientId = entraClientId
RedirectUri = window.location.origin + "/auth/callback"
Scopes = [
"openid"
"profile"
"email"
"offline_access"
// LOAD-BEARING — without this scope Entra mints an opaque
// Microsoft Graph access token, and the server's audience
// validation rejects every authenticated request. App
// registration must also have `requestedAccessTokenVersion: 2`.
sprintf "api://%s/access_as_user" entraClientId
]
PostLogoutRedirectUri = None
ValidateIdToken = None
}
Equivalent post-migration:
// CONSUMER `Client.fs` — preset shape (one call, provenance metadata):
let entraTenantId = __ENTRA_TENANT_ID__
let entraClientId = __ENTRA_CLIENT_ID__
let redirectUri = window.location.origin + "/auth/callback"
let oidcCfg, _meta =
OidcPresets.entraWorkforce entraTenantId entraClientId redirectUri
The _meta is the PresetMetadata record the preset emits alongside the config. It carries:
Name = "entra-workforce"— stable identifier for metric tagging.IssuerForm = "https://login.microsoftonline.com/{tenantGuid}/v2.0"— operator-facing description the coherence validator (Phase C) renders.AutoAddedScopes = [ "api://{clientId}/access_as_user" ]— exactly what the preset added.ExpectsDecodableAccessToken = true— affects the classifier's expectations and the validator's audience-binding hints.Notes = [ ... ]— human-readable preset-applied invariants (tenant-must-be-GUID, scope load-bearing rationale, app-registration token-version requirement).
Consumers wanting to override any default can record-update post-call:
let oidcCfg, meta =
OidcPresets.entraWorkforce entraTenantId entraClientId redirectUri
let cfgWithValidator = { oidcCfg with ValidateIdToken = Some true }
Worked migration — Entra External ID consumer
External ID has a dedicated companion (ToolUp.AuthProviders.EntraExternalId.Client) that already does this work plus a sign-up affordance via user-flow policy routing. The preset is the single-call path for the no-sign-up-policy case:
// pre-preset (dedicated companion path):
ClientConfig.compose
{| AuthUI = CustomAuthUI { Wrap = EntraExternalIdAuthUI.wrap externalIdCfg } |}
// preset (single-call path, no user-flow split):
let oidcCfg, _ = OidcPresets.entraExternalId tenantSubdomain clientId redirectUri
let withCustomDomain, _ =
OidcPresets.entraExternalIdWithDomain tenantSubdomain "login.mybrand.com" clientId redirectUri
The dedicated EntraExternalIdClient companion continues to work; it remains the right choice when the deployment surfaces a separate sign-up button via the SignUpPolicyId mechanism. The preset is the right choice when only sign-in is needed and the consumer wants the single-call shape.
Worked migration — Auth0 consumer
// preset:
let oidcCfg, _ = OidcPresets.auth0 "your-tenant.auth0.com" clientId redirectUri
// Or with a regional Auth0 tenant:
let oidcCfg, _ = OidcPresets.auth0 "your-tenant.eu.auth0.com" clientId redirectUri
Auth0 access tokens are opaque by default. Consumers needing a decodable JWT (typical when the application server validates the access token directly rather than via Auth0's /userinfo) configure an API audience in the Auth0 dashboard and pass it as an audience extra parameter via beginSignInWithExtras:
OidcClient.beginSignInWithExtras
cfg
[ "audience", "https://api.yourapp.com" ]
Verification steps
After adopting a preset:
dotnet build— the preset returns anOidcUIConfigshape identical to whatOidcAuthUI.OidcShellalready accepts; no other code changes required.- Smoke-test sign-in end-to-end — confirm a
[auth] <corr> begin-sign-inline appears in the browser console withTOOLUP_AUTH_TRACEenabled (Phase AAuthTracer), followed bytoken-exchange-okandtransition:established. If the first authenticated request 401s after sign-in, audit the preset's documented invariants (workforce Entra —requestedAccessTokenVersion: 2on the app registration is the most common miss). - Inspect the
PresetMetadata.Notesin the destructured tuple if anything looks off — each note is a self-contained sentence describing a load-bearing knob.
Rollback
Presets coexist with the existing OidcUIConfig constructor. Rolling back means reverting the consumer site only:
// rollback — hand-build the config
let oidcCfg: OidcUIConfig = {
Issuer = "..."
ClientId = "..."
RedirectUri = "..."
Scopes = [ ... ]
PostLogoutRedirectUri = None
ValidateIdToken = None
}
The SDK side of presets is purely additive; no rollback is required there.
Coordinated change at 0.X.0
The 0.X.0 coordinated minor bump retires OidcUIConfig field-by-field in favour of a unified OidcAppConfig record consumed by both server and client. Presets at that point return OidcAppConfig instead of OidcUIConfig * PresetMetadata; the metadata is folded onto the shared record. Consumers that adopt presets now get the smaller migration diff later — the call-site doesn't change, only what the preset returns.
The companion-side deprecation of EntraExternalIdClient lands at 0.X.0 per a separate migration doc; until then both code paths coexist.