Repository navigation
Identity Providers
The WorkflowCore.Dashboard.OpenIdConnect package adds sign-in to the dashboard in one call, with two roles:
- Admin: sees everything, starts, suspends, resumes and terminates workflows, publishes events, uses the designer.
- Viewer: sees everything, changes nothing. The UI hides the action buttons and the API refuses changes.
Everyone else is refused, with a page that shows who they are signed in as and which groups the identity provider sent.
| Provider | Method | Roles come from |
|---|---|---|
| Okta | UseOkta |
Okta groups (groups claim) |
| Microsoft Entra ID | UseEntraId |
App roles (roles claim) |
| Auth0 | UseAuth0 |
Auth0 roles, through a login Action |
| Keycloak | UseKeycloak |
Realm roles (realm_access.roles) or groups |
UseGoogle |
Email addresses and your Workspace domain | |
| Amazon Cognito | UseCognito |
User pool groups (cognito:groups) |
| Any other OpenID Connect provider | UseOpenIdConnect |
The claim you name |
All of them use OpenID Connect with the authorization code flow and PKCE. The package targets .NET 8, 9 and 10.
-
Install the package:
dotnet add package WorkflowCore.Dashboard.OpenIdConnect
-
In the Okta Admin Console, create the app and the groups claim as described under Okta. Note the client ID and secret.
-
Add sign-in to the dashboard:
using WorkflowCore.Dashboard.OpenIdConnect; builder.Services.AddWorkflowCoreDashboard() .UseOkta(okta => { okta.Domain = "acme.okta.com"; okta.ClientId = builder.Configuration["Okta:ClientId"]!; okta.ClientSecret = builder.Configuration["Okta:ClientSecret"]; okta.AdminGroups.Add("Workflow Admins"); okta.ViewerGroups.Add("Engineering"); }); var app = builder.Build(); app.MapWorkflowCoreDashboard("/workflows");
-
Run the app and open
/workflows. You are sent to Okta, then back to the page you asked for.
WebApplication adds the authentication and authorization middleware by itself. If your app calls UseRouting() explicitly, call UseAuthentication() and UseAuthorization() after it.
-
URLs to register with the provider (replace the host, and
/workflowsif you mapped the dashboard elsewhere):Purpose URL Sign-in redirect (callback) https://your-app.example.com/workflows/signin-oidcSign-out redirect https://your-app.example.com/workflows/signout-callback-oidcAmazon Cognito is the exception for sign-out: see its section.
-
Its own session. The dashboard keeps its own sign-in cookie,
.WorkflowCoreDashboard, sent only to/workflows. Your app's sign-in, if it has one, is untouched. The cookie isHttpOnlyandSameSite=Lax, holds the user's name, email and dashboard role, and lastsSessionLifetime(8 hours) after the last activity. -
Roles are decided at sign-in. A user whose groups change gets the new role at their next sign-in.
-
Sign-out (account menu, top right) ends the dashboard session and the session at the provider.
-
Expired sessions: API calls return
401and the page reloads, which signs the user in again (often without a prompt, while the provider's session lasts).
| Option | Meaning |
|---|---|
AdminGroups, ViewerGroups
|
Values of the groups claim (GroupsClaim) that make someone an admin or viewer. Compared without case. |
AdminUsers, ViewerUsers
|
Email addresses. Only used when the provider says the address is verified. |
AllowedDomains |
When set, only addresses in these domains get any role. |
DefaultRole |
Role for signed-in users nothing else matches: None (default, refused), Viewer or Admin. |
Admin wins when several rules match. For example, "the whole company can watch, the platform team can act":
okta.DefaultRole = DashboardRole.Viewer;
okta.AdminGroups.Add("Platform Team");DefaultRole = Viewer lets every account your provider lets through see the dashboard. Limit who can sign in to the app at the provider (most call this assignment) or with AllowedDomains.
UseSignIn reads everything from configuration, so each environment can point at its own provider and the secret can come from an environment variable or a vault:
builder.Services.AddWorkflowCoreDashboard()
.UseSignIn(builder.Configuration.GetSection("Dashboard:SignIn"));{
"Dashboard": {
"SignIn": {
"Provider": "Okta",
"Domain": "acme.okta.com",
"ClientId": "0oa1b2c3d4e5f6g7h8i9",
"AdminGroups": [ "Workflow Admins" ],
"ViewerGroups": [ "Engineering" ]
}
}
}# Never commit the secret: set it in the environment (or user secrets, Key Vault, AWS Secrets Manager…).
export Dashboard__SignIn__ClientSecret='…'Provider is one of Okta, EntraId, Auth0, Keycloak, Google, Cognito or OpenIdConnect. The other keys are the options of that provider.
-
Create the app. Admin Console → Applications → Applications → Create App Integration → OIDC - OpenID Connect → Web Application.
- Sign-in redirect URIs:
https://your-app.example.com/workflows/signin-oidc - Sign-out redirect URIs:
https://your-app.example.com/workflows/signout-callback-oidc - Assignments: the people or groups allowed to sign in at all.
- Sign-in redirect URIs:
-
Copy the client ID and client secret from the app's General tab.
-
Send the groups. Security → API → Authorization Servers →
default→ Claims → Add Claim:- Name:
groups - Include in token type: ID Token, Always
- Value type: Groups
- Filter: Matches regex
.*, or Starts withWorkflowto send only the relevant groups
- Name:
-
Configure:
.UseOkta(okta => { okta.Domain = "acme.okta.com"; // your Okta domain, or your custom domain okta.ClientId = "…"; okta.ClientSecret = "…"; okta.AdminGroups.Add("Workflow Admins"); // Okta group names okta.ViewerGroups.Add("Engineering"); })
No custom authorization server? Okta orgs without API Access Management only have the org authorization server. Put the groups claim on the app instead (Sign On tab → OpenID Connect ID Token → Groups claim type Filter, Groups claim filter groups Matches regex .*) and set okta.AuthorizationServerId = null. The dashboard then also asks for the groups scope.
Use app roles: their values arrive in the roles claim by name. The groups claim carries group object IDs (GUIDs), and is left out for users in more than 200 groups.
-
Register the app. Entra admin center → App registrations → New registration.
- Supported account types: Accounts in this organizational directory only.
- Redirect URI, platform Web:
https://your-app.example.com/workflows/signin-oidc - Then, under Authentication, add a second Web redirect URI:
https://your-app.example.com/workflows/signout-callback-oidc
-
Create a secret: Certificates & secrets → New client secret. Copy the value.
-
Create the roles: App roles → Create app role, twice:
- Display name
Workflow admin, allowed member types Users/Groups, valueWorkflow.Admin - Display name
Workflow viewer, allowed member types Users/Groups, valueWorkflow.Viewer
- Display name
-
Assign them: Enterprise applications → your app → Users and groups → Add user/group, pick the role. Assigning groups needs Entra ID P1 or P2. Set Properties → Assignment required to Yes to keep everyone else from signing in.
-
Configure:
.UseEntraId(entra => { entra.TenantId = "…"; // Directory (tenant) ID entra.ClientId = "…"; // Application (client) ID entra.ClientSecret = "…"; entra.AdminGroups.Add("Workflow.Admin"); // app role values entra.ViewerGroups.Add("Workflow.Viewer"); })
TenantId must be your directory. common, organizations and consumers are refused at startup: they would let accounts from other organizations sign in. For national clouds, set Instance, e.g. https://login.microsoftonline.us.
-
Create the app. Applications → Create Application → Regular Web Applications. In Settings:
- Allowed Callback URLs:
https://your-app.example.com/workflows/signin-oidc - Allowed Logout URLs:
https://your-app.example.com/workflows/signout-callback-oidc - Copy the Domain, Client ID and Client Secret.
- Allowed Callback URLs:
-
Create the roles: User Management → Roles:
workflow-adminandworkflow-viewer. Assign them to users. -
Put the roles in the ID token. Auth0 leaves them out by default. Actions → Library → Create Action → trigger Login / Post Login:
exports.onExecutePostLogin = async (event, api) => { // Custom claims need a URL-like name; it does not have to resolve. api.idToken.setCustomClaim('https://workflows.example.com/roles', event.authorization?.roles ?? []); };
Deploy it, then add it to the post-login trigger (Actions → Triggers).
-
Configure:
.UseAuth0(auth0 => { auth0.Domain = "acme.eu.auth0.com"; // or your custom domain auth0.ClientId = "…"; auth0.ClientSecret = "…"; auth0.GroupsClaim = "https://workflows.example.com/roles"; auth0.AdminGroups.Add("workflow-admin"); auth0.ViewerGroups.Add("workflow-viewer"); })
Sign-out uses Auth0's OpenID Connect logout endpoint. Tenants created before November 2023 may need Settings → Advanced → RP-Initiated Logout End Session Endpoint Discovery turned on.
-
Create the client. Your realm → Clients → Create client: type OpenID Connect, client ID
workflow-dashboard, Client authentication on, Standard flow on.- Valid redirect URIs:
https://your-app.example.com/workflows/signin-oidc - Valid post logout redirect URIs:
https://your-app.example.com/workflows/signout-callback-oidc - Copy the secret from the Credentials tab.
- Valid redirect URIs:
-
Create the roles: Realm roles →
workflow-adminandworkflow-viewer. Assign them to users or groups. -
Put the roles in the ID token. Keycloak only puts them in the access token by default. Client scopes →
roles→ Mappers →realm roles→ turn on Add to ID token and Add to userinfo. -
Configure:
.UseKeycloak(keycloak => { keycloak.BaseUrl = "https://sso.example.com"; keycloak.Realm = "ops"; keycloak.ClientId = "workflow-dashboard"; keycloak.ClientSecret = "…"; keycloak.AdminGroups.Add("workflow-admin"); // realm role names keycloak.ViewerGroups.Add("workflow-viewer"); })
Groups instead of roles: add a Group Membership mapper (claim name groups, Full group path off, Add to ID token on) and set keycloak.GroupsClaim = "groups". Client roles: set GroupsClaim = "resource_access.workflow-dashboard.roles" and turn on Add to ID token for the client roles mapper.
Google has no groups in its tokens, so roles come from email addresses and your Google Workspace domain.
-
Create the client. Google Cloud console → APIs & Services → OAuth consent screen: user type Internal (only your Workspace). Then Credentials → Create credentials → OAuth client ID → Web application.
- Authorized redirect URIs:
https://your-app.example.com/workflows/signin-oidc
- Authorized redirect URIs:
-
Configure:
.UseGoogle(google => { google.ClientId = "….apps.googleusercontent.com"; google.ClientSecret = "…"; google.AllowedDomains.Add("example.com"); // your Workspace domain google.DefaultRole = DashboardRole.Viewer; // everyone in it can watch google.AdminUsers.Add("ada@example.com"); // these people can act })
With AllowedDomains, the account must also be managed by that Workspace (Google's hd claim). An address alone is not enough: anyone can register a personal Google account with a company address, and it keeps working after they leave.
Signing out ends the dashboard session only. Google has no sign-out endpoint for apps.
-
Create the app client. Your user pool → App integration → App clients → Create app client: type Traditional web application (confidential, with a client secret).
- Allowed callback URLs:
https://your-app.example.com/workflows/signin-oidc - Allowed sign-out URLs:
https://your-app.example.com/workflows/signed-out - OAuth grant type Authorization code, scopes
openid,email,profile.
- Allowed callback URLs:
-
Note the domain of the user pool (Branding → Domain, e.g.
acme.auth.eu-west-1.amazoncognito.com). It is needed for signing out, because Cognito's logout is not the standard one. -
Create the groups: Groups →
workflow-adminandworkflow-viewer. Add users. Cognito puts them in the ID token ascognito:groups. -
Configure:
.UseCognito(cognito => { cognito.Region = "eu-west-1"; cognito.UserPoolId = "eu-west-1_AbC123dEf"; cognito.Domain = "acme.auth.eu-west-1.amazoncognito.com"; cognito.ClientId = "…"; cognito.ClientSecret = "…"; cognito.AdminGroups.Add("workflow-admin"); cognito.ViewerGroups.Add("workflow-viewer"); })
UseOpenIdConnect works with any OpenID Connect provider. Give it the issuer URL and the name of the claim that carries groups or roles:
.UseOpenIdConnect(oidc =>
{
oidc.ProviderName = "PingOne"; // shown on error pages
oidc.Authority = "https://auth.pingone.com/<environment-id>/as";
oidc.ClientId = "…";
oidc.ClientSecret = "…";
oidc.GroupsClaim = "groups";
oidc.AdminGroups.Add("Workflow Admins");
})| Provider | Authority |
Roles or groups |
|---|---|---|
| PingOne | https://auth.pingone.com/<environment-id>/as |
Add a groups attribute (group names) to the app's attribute mappings |
| PingFederate | your PingFederate base URL | Map group membership to an ID token claim in the OpenID Connect policy |
| OneLogin | https://<subdomain>.onelogin.com/oidc/2 |
App → Parameters → Groups, sent as groups
|
| JumpCloud | https://oauth.id.jumpcloud.com/ |
App → Include group attribute; set GroupsClaim to the attribute name |
| Zitadel | https://<instance>.zitadel.cloud |
Turn on User roles inside ID Token; GroupsClaim = "urn:zitadel:iam:org:project:roles"
|
| Authentik | https://authentik.example.com/application/o/<app-slug>/ |
groups, sent with the profile scope |
GroupsClaim accepts:
- a claim sent once per group, or as a JSON array (
groups); - a JSON object whose keys are the roles (Zitadel);
- a path into a JSON claim (
realm_access.roles); - a URL-like name (
https://example.com/roles).
To ask for more scopes, add them to Scopes. For anything else, ConfigureOpenIdConnect gives you the underlying OpenIdConnectOptions.
Common to every provider:
| Option | Default | Meaning |
|---|---|---|
ClientId |
(required) | The app's client ID. |
ClientSecret |
null |
The app's client secret. Keep it out of source control. |
Authority |
set by the provider method | Issuer URL. Only needed with UseOpenIdConnect. |
GroupsClaim |
per provider | Claim with groups or roles; null when the provider sends none. |
EmailClaim |
email (preferred_username for Entra ID) |
Claim with the email address. |
AdminGroups, ViewerGroups
|
empty | See Choosing who gets which role. |
AdminUsers, ViewerUsers
|
empty | Email addresses. |
AllowedDomains |
empty | Email domains allowed any role. |
DefaultRole |
None |
Role when nothing else matches. |
Scopes |
openid, profile, email
|
Scopes requested. |
SessionLifetime |
8 hours | Session length without activity. |
RequireHttpsMetadata |
true |
Only turn off for a local test provider on plain HTTP. |
ConfigureOpenIdConnect |
null |
Last say over the ASP.NET Core OpenIdConnectOptions. |
Per provider:
| Provider | Options |
|---|---|
| Okta |
Domain, AuthorizationServerId (default; null for the org authorization server) |
| Entra ID |
TenantId, Instance (https://login.microsoftonline.com) |
| Auth0 | Domain |
| Keycloak |
BaseUrl, Realm
|
| Cognito |
Region, UserPoolId, Domain
|
Mistakes such as a missing client ID, an HTTP authority, or rules that let nobody in stop the app at startup with a message naming the provider and the option.
-
HTTPS. Providers refuse plain HTTP redirect URLs outside
localhost. -
Behind a reverse proxy or load balancer that terminates TLS, forward the original scheme and host, or the redirect URL sent to the provider starts with
http://:builder.Services.Configure<ForwardedHeadersOptions>(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost; o.KnownProxies.Add(IPAddress.Parse("10.0.0.10")); // your proxy }); app.UseForwardedHeaders(); // before the dashboard
-
Several instances, or containers that restart: share ASP.NET Core Data Protection keys (
AddDataProtection().PersistKeysTo…). The session cookie and the sign-in state are encrypted with them. Without shared keys, users are signed out at random or sign-in fails with "Correlation failed". -
Client secret in environment variables or a secret store, never in
appsettings.jsonin source control.
| What you see | Cause and fix |
|---|---|
| "You don't have access" | The page lists the groups the provider sent. Empty: the groups claim is missing; see your provider's section. Names differ: fix AdminGroups/ViewerGroups. The server also logs a warning with the claim it received. |
| The provider says the redirect URI is invalid | Register the exact sign-in URL above. Behind a proxy, check the URL in the address bar at the provider: if it says http://, see forwarded headers above. |
| "Correlation failed" after sign-in | The browser dropped the sign-in cookie: mixed http/https, several instances without shared Data Protection keys, or a sign-in that took longer than 15 minutes. |
| Sign-out shows an error at the provider | Register the sign-out redirect URL above (for Cognito, /workflows/signed-out). |
| Endless redirects | Another middleware or policy refuses the dashboard's own cookie. Do not add RequireAuthorization(...) with a different scheme on the dashboard group; the package sets the policy for you. |
The repository has a ready Keycloak realm with three users:
docker compose -f samples/keycloak/docker-compose.yml up -d
dotnet run --project samples/WorkflowCore.Dashboard.Sample --launch-profile KeycloakOpen http://localhost:5290/workflows and sign in as:
| User | Password | Role |
|---|---|---|
ada |
ada |
Admin |
val |
val |
Viewer |
mo |
mo |
None: sees the "access denied" page |
The sample reads its sign-in settings from appsettings.Keycloak.json. Point Dashboard:SignIn at your own provider to try that one.
Start
Guides
Reference