Skip to content
GitHub

Authentication

The Fleans REST API supports opt-in JWT bearer authentication via any OIDC-compliant identity provider (Keycloak, Auth0, Microsoft Entra ID, etc.). Authentication is disabled by default — when no Authentication:Authority is configured, the API runs fully unauthenticated, identical to previous behavior.

Local development and single-tenant deployments often don’t need authentication. Production multi-tenant deployments do. By making it configuration-driven, the same binary serves both scenarios without recompilation.

Add the Authentication section to your appsettings.json:

{
"Authentication": {
"Authority": "https://your-idp.example.com/realms/fleans",
"Audience": "fleans-api",
"RequireHttpsMetadata": true
}
}

Once Authority is set, all API endpoints (/Definitions/*, /Execution/*, /Instances/*, /UserTasks/*, /custom-tasks) require a valid Authorization: Bearer <token> header. Requests without a token receive 401 Unauthorized.

Environment variable equivalent (for Docker Compose or container deployments):

Terminal window
Authentication__Authority=https://your-idp.example.com/realms/fleans
Authentication__Audience=fleans-api
Authentication__RequireHttpsMetadata=false
KeyRequiredDefaultDescription
AuthorityYes (to enable auth)(absent — auth disabled)OIDC issuer URL. When set, all API endpoints require a valid JWT.
AudienceNofleans-api (MCP server: fleans-mcp)Expected aud claim in the JWT.
RequireHttpsMetadataNotrueSet to false only for local dev with an HTTP-only IdP (e.g., Keycloak dev mode).
UserIdClaimNopreferred_usernameJWT claim holding the acting user id for user-task claim/complete. Its values must match what your BPMN camunda:assignee / camunda:candidateUsers contain. sub is also accepted.
GroupsClaimNogroupsJWT claim holding the caller’s groups for candidateGroups checks and deploy reviewer groups (one claim entry per group). Also read by the Admin UI from the sign-in (ID token or UserInfo). Set it to roles to use roles instead.
  • All API endpoints (/Definitions/*, /Execution/*, /Instances/*, /UserTasks/*, /custom-tasks) require a valid Authorization: Bearer <token> header. Unauthenticated requests receive 401 Unauthorized.
  • The MCP server (Fleans.Mcp) reads the same Authentication keys and enforces bearer tokens on its MCP endpoint with audience fleans-mcp. The Aspire AppHost and the Helm chart pass one Authority to the API, the MCP server and the admin UI.
  • Health endpoints (/health, /alive) remain anonymous — they are exempt so that load balancers and orchestrators can probe without credentials. See Fleans.ServiceDefaults/Extensions.cs for the implementation.
  • Swagger UI remains accessible in development mode for testing.
  • User-task identity comes from the token. POST /UserTasks/{id}/claim and /complete act as the user in the UserIdClaim claim (default preferred_username). The body UserId field is optional. If you send it and it differs from the token, the request fails with 403 Forbidden, so a caller can’t act as another user. Body-supplied UserGroups are ignored; groups come from the GroupsClaim claim. A token without the user-id claim also gets 403. This applies to releases after v0.5.0 (#793). v0.5.0 and earlier trust the body UserId even under JWT.
With Authentication:Authority set, every API call carries a bearer token; user-task claims take candidate groups from the token's groups claim. Open interactive diagram ↗
Terminal window
# 1. Start Keycloak dev instance
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
# 2. Create realm "fleans", client "fleans-api" with client credentials grant
# 3. Configure Fleans
# appsettings.json:
# "Authentication": {
# "Authority": "http://localhost:8080/realms/fleans",
# "Audience": "fleans-api",
# "RequireHttpsMetadata": false
# }
# 4. Obtain a token and call the API
TOKEN=$(curl -s -X POST http://localhost:8080/realms/fleans/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=fleans-api" \
-d "client_secret=YOUR_SECRET" | jq -r '.access_token')
curl -X POST https://localhost:7140/Definitions/deploy \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"BpmnXml":"..."}'

Configuration is identical — set Authority to your provider’s OIDC discovery URL and Audience to the configured client/API identifier. Provider-specific walkthroughs will be added as contributors submit them.

Terminal window
# Obtain a token from your identity provider, then:
TOKEN="<your-jwt-token>"
curl -X POST https://localhost:7140/Execution/start \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"WorkflowId":"my-process"}'

Use a DelegatingHandler to attach the bearer token automatically on every outgoing request:

public class BearerTokenHandler : DelegatingHandler
{
private readonly ITokenProvider _tokenProvider;
public BearerTokenHandler(ITokenProvider tokenProvider)
=> _tokenProvider = tokenProvider;
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _tokenProvider.GetTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
// Registration (e.g., in Program.cs):
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddHttpClient<IFleansClient, FleansClient>(client =>
{
client.BaseAddress = new Uri("https://localhost:7140");
})
.AddHttpMessageHandler<BearerTokenHandler>();

The Fleans.Web Blazor admin UI supports the same opt-in pattern via OIDC Authorization Code flow with PKCE, persisting the resulting identity in a session cookie. Disabled by default — when no Authentication section is present, the UI runs unauthenticated, identical to today’s behaviour.

Browser users don’t hold raw JWTs. The canonical pattern for server-side web apps is OIDC Authorization Code flow with PKCE, which exchanges the authorization code for tokens server-side and persists the identity in an encrypted session cookie. The API uses bearer JWTs because its callers are scripts and services that do hold tokens; the UI uses cookies because its callers are humans in browsers.

{
"Authentication": {
"Authority": "https://your-idp.example.com/realms/fleans",
"ClientId": "fleans-web",
"ClientSecret": "<from-IdP>",
"RequireHttpsMetadata": true,
"CookieExpireMinutes": 60,
"KnownProxies": [],
"KnownNetworks": []
}
}

Auth on iff both Authority AND ClientId are non-empty (single source of truth — same key namespace as the API). The shipped appsettings.json carries no Authentication block; a documented copy lives at src/Fleans/Fleans.Web/appsettings.example.jsonc and is not loaded at runtime.

KeyRequiredDefaultDescription
AuthorityYes (to enable auth)(absent — auth disabled)OIDC issuer URL.
ClientIdYes (to enable auth)(absent)OAuth client id registered with the IdP. Must be a confidential client.
ClientSecretYes when auth on—Confidential-client secret. Use dotnet user-secrets in dev; env var Authentication__ClientSecret (or a Secret/Key Vault binding) in prod. Never commit.
RequireHttpsMetadataNotrueSet to false only for local Keycloak dev mode (HTTP).
CookieExpireMinutesNo60Sliding session cookie lifetime. Mirror the IdP’s access-token / management-page session lifetime so admin sessions in the UI and tokens used against the API don’t drift apart.
KnownProxiesNo[]Reverse-proxy IP addresses trusted to set X-Forwarded-For / X-Forwarded-Proto. Empty list = headers ignored.
KnownNetworksNo[]Same as above but accepts CIDR ranges (e.g. 10.0.0.0/8).

Environment variable equivalent (for Docker Compose or container deployments):

Terminal window
Authentication__Authority=https://your-idp.example.com/realms/fleans
Authentication__ClientId=fleans-web
Authentication__ClientSecret=... # source from a Secret/Key Vault binding in prod, not literal
  • Every page is wrapped in <AuthorizeRouteView>. Unauthenticated requests trigger an OIDC challenge → IdP login → callback to /signin-oidc → session cookie issued → bounce to the originally-requested URL (deep links preserved).
  • Orleans Dashboard at /dashboard is gated by an explicit middleware branch — Orleans’ dashboard middleware does not honour [Authorize], so the guard fires before MapOrleansDashboard.
  • NavMenu renders a Signed in as <preferred_username> chip with a Sign out button. The button submits an antiforgery-protected POST to /Account/Logout that clears both the cookie and the IdP session.
  • /health, /alive stay anonymous (operator probes are unaffected).
  • /Account/Login?returnUrl=… validates returnUrl against an inline IsLocalUrl predicate (same shape as IUrlHelper.IsLocalUrl). Open-redirect attacks (/\evil.com, //evil.com, absolute URLs, leading whitespace) collapse to /; well-formed local paths pass through.

When Fleans.Web runs as more than one replica, ASP.NET Data Protection keys are persisted to the existing orleans-redis Aspire resource so cookies issued by replica A decrypt on replica B. Single-replica deployments use the same path (it’s a no-op on key cardinality, not behaviour).

If Fleans.Web sits behind a reverse proxy that terminates TLS, populate KnownProxies (or KnownNetworks for CIDR ranges) so X-Forwarded-For / X-Forwarded-Proto are honoured when constructing OIDC redirect URIs. Empty defaults are deliberately strict — without an explicit allowlist the framework discards the headers, preventing host-spoofing from untrusted networks.

A copy-paste Keycloak quickstart (Docker run, realm JSON, dotnet user-secrets commands, sample user) lives at tests/manual/30-web-auth/keycloak-dev.md. The full manual test plan (login round-trip, dashboard guard, open-redirect attack table, antiforgery on logout) is at tests/manual/30-web-auth/test-plan.md.

This slice authenticates only — every signed-in user has the same access. Role-based policies (Admin, Operator, Viewer) and per-page [Authorize(Roles=…)] are deliberately a separate slice, mirroring the same staging used for the API in #341. The OIDC handler already maps the roles claim from the token; the follow-up slice adds policy registration and component-level enforcement.

The manual regression test plans for authentication live at:

  • API: tests/manual/28-api-auth/test-plan.md — verifies the API works unauthenticated by default, returns 401 when auth is on and no token is provided, and accepts valid tokens.
  • Management UI: tests/manual/30-web-auth/test-plan.md — verifies anonymous browse is allowed when no Authentication section is present, every page (and /dashboard) returns 302 → IdP when auth is on, login round-trip preserves deep-link query strings, the open-redirect guard rejects every canonical attack input, and /Account/Logout is antiforgery-protected.

Common errors:

SymptomLikely cause
401 with no WWW-Authenticate response headerAuthority URL is unreachable or the OIDC discovery endpoint returned an error at startup
401 with invalid_token in WWW-AuthenticateAudience mismatch — the token’s aud claim doesn’t match the configured value
401 on /health or /aliveNot expected — these endpoints are always anonymous; check for a reverse proxy stripping the path
403 UserId does not match the authenticated user on claim/completeThe body UserId differs from the token’s UserIdClaim value. Omit UserId or send the token’s value
403 Authenticated user has no user id claim on claim/completeThe token lacks the UserIdClaim claim. Add a mapper in your IdP or point Authentication:UserIdClaim at a claim the token carries
  • Role-based authorization for both API and Management UI — tracked in #341.
  • Observability — health checks, metrics, logging, tracing, dashboards, alerting
  • Deployment — how to wire Authentication:Authority / Authentication:Audience (and the OIDC client id/secret for the admin UI) into Docker Compose, Kubernetes, and bare-VM deployments, including reverse-proxy + KnownProxies notes.
  • Self-Hosting on Kubernetes — OIDC opt-in on the Helm chart.