Sign-in, provisioning and access¶
Sign-in (OpenID Connect)¶
People sign in with Google or any OpenID Connect provider. The authorisation code flow uses
PKCE, a nonce and a signed state cookie; sessions are server-side (revocable) and last
WFM_SESSION_TTL (7 days by default).
Google — create an OAuth client (type Web application) in Google Cloud Console with the
redirect URI https://<WFM_PUBLIC_URL host>/auth/google/callback, then:
WFM_GOOGLE_CLIENT_ID=…apps.googleusercontent.com
WFM_GOOGLE_CLIENT_SECRET=…
WFM_ALLOWED_DOMAINS=example.com # strongly recommended
For Google the domain check uses the signed hd (hosted domain) claim, never the email suffix,
so personal Gmail accounts cannot sign in to a Workspace-restricted install.
Other providers (Okta, Entra ID, Keycloak, Authentik…) — list them by id:
WFM_OIDC_PROVIDERS=google,okta
WFM_OIDC_OKTA_ISSUER=https://example.okta.com
WFM_OIDC_OKTA_CLIENT_ID=…
WFM_OIDC_OKTA_CLIENT_SECRET=…
WFM_OIDC_OKTA_DISPLAY_NAME=Okta # optional
WFM_OIDC_OKTA_ALLOWED_DOMAINS=example.com # optional; overrides WFM_ALLOWED_DOMAINS
WFM_OIDC_OKTA_SCOPES=openid,email,profile # optional
Redirect URI: https://<host>/auth/<id>/callback. For non-Google providers the allowed-domain
check uses the email's domain, and the provider must assert email_verified.
A person is matched to an existing account by provider subject, then (Google) Google id, then verified email. Deactivated and deleted accounts cannot sign in.
Groups from the sign-in token¶
With WFM_OIDC_<ID>_GROUPS_CLAIM set, each sign-in reads groups from the ID token and makes them
the person's groups from that provider, so group → role mappings apply
without a directory sync:
WFM_OIDC_KEYCLOAK_GROUPS_CLAIM=groups,realm_access.roles # dotted claim paths, comma-separated
WFM_OIDC_KEYCLOAK_GROUPS_PREFIX=wfm- # optional: only groups starting with it
- Every string (or list of strings) at those paths counts; a leading
/(Keycloak group paths) is trimmed, and the prefix is matched after that. - The groups appear under Administration → Groups as
<provider>/<group>with source Sign-in; give them an organisation role there. Their names belong to the provider. - The token is authoritative: a group missing from the next sign-in's token is left, and so is its role. Groups from other providers, directory sync, SCIM or made here are untouched.
- The claim must be in the ID token (in Keycloak: the group or role mapper's Add to ID token). At most 200 groups are taken from one token.
- When Keycloak directory sync also syncs groups for the same provider, the sync is the source of groups and the claim is ignored, so each group exists once.
Signing out at the provider¶
Sessions remember which provider session they came from (its sid and subject, and the ID token,
sealed), so sign-out can work both ways:
- Back-channel logout — when someone signs out at the provider, or an admin ends their session
there, the provider calls
POST https://<host>/auth/<id>/backchannel-logoutand Workflow ends that session (bysid; by subject when the token has nosid). Register that URL as the client's back-channel logout URL (Keycloak: Backchannel logout URL, with session required on). The logout token must be signed by the provider for this client, be unexpired, carry the back-channel logout event and no nonce; anything else gets400. - Single logout — with
WFM_OIDC_<ID>_SINGLE_LOGOUT=true, signing out of Workflow also signs out of the provider (RP-initiated logout): the browser goes to the provider'send_session_endpointwith the ID token as hint and comes back to/login. Registerhttps://<host>/loginas a valid post-logout redirect URI. Providers without an end-session endpoint are signed out of Workflow only.
Who gets an account¶
Pick one source of truth:
| When to use | Accounts are created | Deactivated when | |
|---|---|---|---|
| SCIM | Okta, Entra ID, OneLogin, JumpCloud, Google (via a SCIM connector) | by the IdP when people are assigned to the app | unassigned / active=false / deleted in the IdP |
| Google directory sync | Google Workspace without a SCIM connector | every WFM_GOOGLE_SYNC_INTERVAL (15 min) for members of the access group or org unit |
suspended, archived or removed from the group/OU; deleted |
| Keycloak directory sync | Keycloak (its SCIM support only receives users) | every WFM_KEYCLOAK_SYNC_INTERVAL (15 min) for members of the access group and its subgroups |
disabled or removed from the access group; deleted |
| Just-in-time | small teams | on first sign-in | an admin deactivates them |
WFM_JIT_PROVISIONING=auto (default) enables just-in-time accounts only while no SCIM
connection exists and directory sync is off; set true or false to force it.
WFM_BOOTSTRAP_ADMINS=alice@example.com,… makes those people organisation admins whenever they
sign in or are provisioned. With none set, the very first account becomes the admin.
Deactivation is immediate: sessions are deleted and API tokens revoked. People deleted upstream
are deactivated, then anonymised after WFM_DELETED_USER_RETENTION (90 days) — their issues,
comments and history remain, attributed to "Former user". Re-provisioning someone within the
retention period restores the same account.
SCIM 2.0¶
Administration → Sign-in & provisioning → New SCIM connection gives you the base URL
(<WFM_HOOKS_PUBLIC_URL>/scim/v2) and a bearer token (shown once; rotate any time). Create one
connection per IdP.
- Resources:
UsersandGroupswithGET(filters such asuserName eq "…", pagination),POST,PUT,PATCHandDELETE; discovery endpointsServiceProviderConfig,ResourceTypesandSchemas. - User attributes:
userName,name.givenName,name.familyName,displayName,emails[primary],externalId,active. Other attributes are accepted and ignored. - Okta: SCIM 2.0 Test App (Header Auth), base URL as above, unique identifier
userName, enable Push New Users, Push Profile Updates, Push Groups. - Entra ID: Enterprise application → Provisioning → Automatic, Tenant URL = base URL, Secret
Token = the token. Entra's PATCH quirks (
"active": "False", path-less replace, member removal by filter) are supported. - SCIM-managed profiles are read-only in the UI; groups pushed by the IdP can only be mapped to an organisation role here.
Google Workspace directory sync¶
- In Google Cloud, create a service account and a JSON key; enable the Admin SDK API.
- In the Admin console → Security → API controls → Domain-wide delegation, authorise the
service account's client id for:
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.group.member.readonly. - Configure:
WFM_GOOGLE_DIRECTORY_ENABLED=true
WFM_GOOGLE_SERVICE_ACCOUNT_KEY=/secrets/google/key.json # path or the JSON itself
WFM_GOOGLE_ADMIN_SUBJECT=it-admin@example.com # an admin to impersonate (read-only)
WFM_GOOGLE_SYNC_GROUP=workflow-users@example.com # who gets access (and/or:)
WFM_GOOGLE_SYNC_ORG_UNIT=/Engineering
WFM_GOOGLE_SYNC_GROUPS=true # also mirror groups
WFM_GOOGLE_GROUP_PREFIX=team- # only groups whose email starts with this
With neither a group nor an org unit, every user in the domain gets an account. Mirrored groups keep their membership in sync and can be used in project roles. Runs are listed under Administration → Sign-in & provisioning, with a Run now button.
Keycloak¶
People sign in through Keycloak as an OpenID Connect provider (WFM_OIDC_PROVIDERS=keycloak,
issuer https://<keycloak>/realms/<realm>, redirect URI https://<host>/auth/keycloak/callback),
and Workflow can provision them from the realm with directory sync. Only one directory sync
(Google Workspace or Keycloak) can be on at a time.
Keycloak directory sync¶
Keycloak's SCIM support only receives users from elsewhere, so Workflow pulls people and groups through the Keycloak Admin REST API instead, with a service account of its own:
- In the realm, create a client (e.g.
workflow-sync): Client authentication on, Service accounts roles on, every other flow off. Note its secret (Credentials tab). - On its Service accounts roles tab, Assign role → filter by clients →
realm-management→view-usersandquery-groups(read-only;view-usersincludesquery-users). - Configure:
WFM_KEYCLOAK_ENABLED=true
WFM_KEYCLOAK_URL=https://sso.example.com # Keycloak's base URL (no /realms/…)
WFM_KEYCLOAK_REALM=acme
WFM_KEYCLOAK_CLIENT_ID=workflow-sync
WFM_KEYCLOAK_CLIENT_SECRET=…
WFM_KEYCLOAK_PROVIDER=keycloak # the WFM_OIDC_PROVIDERS entry for this realm
WFM_KEYCLOAK_SYNC_GROUP=/workflow-users # who gets an account (a group path)
WFM_KEYCLOAK_SYNC_GROUPS=true # also mirror groups
WFM_KEYCLOAK_GROUP_PREFIX=wfm- # only groups whose name starts with this
WFM_KEYCLOAK_URL is configured by an administrator, so it may be a private address inside the
cluster (http://keycloak.keycloak.svc:8080); the outbound guard that protects user-supplied URLs
does not apply to it. Each request times out after two minutes.
How a run works. Workflow gets a client-credentials token
(POST {URL}/realms/{realm}/protocol/openid-connect/token, renewed shortly before it expires and
once more if Keycloak answers 401), then pages through GET /admin/realms/{realm}/users 100 at a
time. With WFM_KEYCLOAK_SYNC_GROUP set, only direct members of that group (found with
group-by-path) and of its subgroups are in scope; without it, everyone in the realm is. Service
accounts are never provisioned.
- Matching. Each person is linked to the sign-in provider by their Keycloak user id, which is
the
subof their tokens (user_identities: providerWFM_KEYCLOAK_PROVIDER, subject = user id). A Keycloak user is matched to an existing account by that link first, then by email (case-insensitive) — but only by a verified email, so that nobody can claim an account by editing their own address. People without a verified email are not provisioned (they could not sign in either); they and the accounts left alone (below) are counted asusersSkipped. - Accounts get source
keycloak; email, display name (first and last name, else the username) and given/family names follow Keycloak, and the profile is read-only here. Taking over an existing local, just-in-time or development account removes its password and two-factor settings, as with SCIM. Bootstrap admins are made organisation admins. - Left alone: accounts managed by SCIM (even with the same email), service accounts, and accounts linked to another live Keycloak user.
- Disabled in Keycloak, or no longer in the access group: deactivated at the next run — sessions deleted and API tokens revoked at once — and reactivated when enabled or back in scope. Disabled people are never created.
- Deleted in Keycloak: marked deleted (deactivated, anonymised after
WFM_DELETED_USER_RETENTION); re-created within that time with the same email, they get the same account back. - Groups (
WFM_KEYCLOAK_SYNC_GROUPS, on by default): every group in the realm is walked, including subgroups (/groups/{id}/children, Keycloak 23+; older versions nest them in the listing), and kept when its name starts withWFM_KEYCLOAK_GROUP_PREFIX(case-insensitive; a subgroup is judged by its own name, not its parent's). A synced group's name in Workflow is its path without the leading/(engineering/platform): paths are unique in a realm and match agroupsclaim with full paths. A group renamed or moved in Keycloak keeps its identity here (matched by Keycloak id), with its role mappings. Members are the group's direct members that have an account here, added and removed to match at each run; groups deleted upstream (or no longer matching the prefix) are deleted here. Synced groups are read-only, but can be mapped to an organisation role and granted project roles like any group. - Failures — Keycloak unreachable, a rejected client secret, a 403 (the error names the
missing
realm-managementroles), an access group that does not exist — fail the run with that message. Nobody is deactivated or deleted unless the user list and the access group were read in full, and never on the strength of a listing alone: Keycloak pages by offset, which can skip someone while others are deleted mid-run, so each removal is confirmed withGET /users/{id}(deleted?) andGET /users/{id}/groups(still in the access group?).
Each run is recorded with source keycloak and its counts (usersCreated, usersUpdated,
usersReactivated, usersDeactivated, usersDeleted, usersSkipped, groupsCreated,
groupsUpdated, groupsDeleted, membershipsAdded, membershipsRemoved, and the totals
users, groups, memberships) under Administration → Sign-in & provisioning, which also shows
the realm, URL, sign-in provider and access group and has a Run now button. The first run starts
shortly after the worker starts. Just-in-time accounts are off while Keycloak sync is on
(WFM_JIT_PROVISIONING=auto).
To try all of this locally, the repository's k3d setup runs a development Keycloak with a ready realm; see CONTRIBUTING.md.
Local accounts¶
Local accounts sign in with an email address and a password: contractors and others outside
your identity provider, or a small team without single sign-on. They are on by default
(WFM_LOCAL_LOGIN=true); with false the sign-in page shows no password form and every
password, reset and invitation endpoint answers 404.
Adding someone. Administration → Users → Add person, then choose how they sign in:
- Email invitation — a signed, single-use link valid for 7 days. Resend invitation replaces the link, Revoke invitation cancels it. Following the link, they choose a password and then sign in.
- Temporary password — generated and shown to you once; give it to them through a channel you trust. They must choose their own password at their first sign-in.
- Single sign-on (when an OIDC provider is configured) — no password; they are matched by email when they first sign in.
Local groups can be picked at the same time (groupIds on POST /api/v1/admin/users).
Which people can have a password. Anyone who is not managed by an identity provider: people an admin added, people created just in time by single sign-on, and development accounts. Send invitation or Set temporary password in a person's actions menu makes them a local account; they then sign in either way, since single sign-on keeps matching their verified email. Remove password makes them single sign-on only again (and signs them out). People provisioned by SCIM or by Google or Keycloak directory sync can never get a password, and when directory sync takes over an existing local account (by matching its email) the account's password, two-factor settings, recovery codes and open links are deleted.
The first admin without single sign-on. Run, with the server's configuration:
It creates (or promotes) an organisation admin with a local account and prints a temporary password. Running it again issues a new one — the recovery path if every admin is locked out.
Passwords are 12–128 characters and may not be one of the most common passwords (a compact
list from Have I Been Pwned's top 100,000 is built in), a common word padded with digits or
symbols (Summer2024!!, P@ssw0rd123), one character or a short pattern repeated, a run such as
abcdefghijkl, or contain the person's name, the local part of their email or their company's
domain name. Password fields show the strength while you type (the server's own check). Passwords
are hashed with argon2id (19 MiB, 2 iterations, 1 lane — the OWASP recommendation) and
re-hashed at the next sign-in if those parameters change.
- Change password (Profile → Sign-in and security) needs the current password, signs you out everywhere else and emails you.
- Forgot password? on the sign-in page emails a reset link valid for one hour. The answer is the same, and takes as long, whether or not the address has an account; at most three emails per address per 15 minutes. Resetting signs the person out everywhere and emails them; two-factor authentication still applies at the next sign-in. Admins can also Send password reset.
Two-factor authentication (TOTP, RFC 6238: SHA-1, 30-second steps, 6 digits, ±1 step for clock
drift, each code accepted once). Profile → Sign-in and security → Set up: scan the QR code (or
type the key) into an authenticator app, confirm with a code, and save the 10 one-time recovery
codes shown (stored hashed; New recovery codes replaces them). Once on, sign-in asks for a code
— or a recovery code — after the password. Turning it off needs the password and a code. Secrets
are encrypted with WFM_SECRET_KEY. With WFM_LOCAL_REQUIRE_2FA=true, every local account must
set it up at its next sign-in and cannot turn it off. Admins can Reset two-factor for someone who
lost their phone.
Sign-in protection.
- Throttling: 30 attempts a minute per client address across the sign-in endpoints, and a burst of
10 (then 5 a minute) per email and address; beyond that the API answers 429 with
Retry-After. Limits are kept in memory per replica. The client address comes fromX-Forwarded-Foronly when the request arrives through a trusted proxy (WFM_TRUSTED_PROXIES), else it is the peer address. - Lockout:
WFM_LOCAL_LOCKOUT_ATTEMPTS(10) failed passwords or codes in a row — failures more thanWFM_LOCAL_LOCKOUT_DURATION(15 minutes) apart start the count again — lock the account forWFM_LOCAL_LOCKOUT_DURATION, from any address (429, codelocked). The lock is stored in the database; admins see it in the user list and can Unlock. Addresses without an account lock the same way, so a lockout reveals nothing. - Errors are generic: a wrong email and a wrong password get the same 401, after the same argon2id work. Only someone who knows the right password learns that an account is deactivated.
- A finished sign-in is the same server-side session as single sign-on. Until then (a code, a
password change or two-factor enrolment is still needed) the browser holds only a short-lived
HttpOnly challenge cookie for
/api/v1/auth/local, good for 5 attempts in 10 minutes. - Personal access tokens cannot change passwords or two-factor settings.
Audit log. user.login (with method: password), user.login_failed, user.locked,
user.password_changed, user.password_reset_requested, user.password_reset,
user.invitation_accepted, user.totp_enabled, user.totp_disabled,
user.recovery_code_used, user.recovery_codes_regenerated, and the admin actions
user.invitation_sent, user.invitation_revoked, user.temporary_password_set,
user.password_reset_sent, user.unlocked, user.totp_reset and user.password_removed, each
with the client address.
API. Sign-in: POST /api/v1/auth/local/login, then …/login/totp, …/login/new-password,
…/login/totp-setup and …/login/totp-setup/confirm as the result's status asks;
…/forgot-password, …/link and …/link/redeem for emailed links; …/password-check for the
strength meter. Profile: GET /api/v1/me/security, POST /api/v1/me/password,
/me/totp/setup, /me/totp/confirm, /me/totp/disable, /me/totp/recovery-codes.
Administration: /api/v1/admin/users/{id}/invitation (POST, DELETE), …/temporary-password,
…/password-reset, …/unlock, …/totp (DELETE) and …/password (DELETE); the user list's
localAccount field reports each person's status.
Roles and permissions¶
Organisation roles: admin (everything, including administration), member (sees open
projects and can create projects), guest (only projects they are explicitly added to). A group
can be mapped to an organisation role; a person gets the highest of their own and their
groups' roles. customer is for the people a service desk
supports: they only use the help centre, groups never raise their role, and they cannot be
project members.
Projects are open (every member gets the project's open role, member by default) or
private. Project roles — built-in viewer, member, admin, plus custom roles — are granted
to people or groups. Permissions:
| Permission | |
|---|---|
project.browse |
see the project and its issues (required by every role) |
issue.create, issue.edit, issue.edit_others, issue.delete |
|
issue.transition, issue.assign, issue.comment, comment.moderate |
|
sprint.manage, release.manage, board.manage |
planning |
project.admin |
settings, members, workflows, automation, repositories, webhooks |
Someone's permissions in a project are the union of the roles granted to them directly and via
groups. An explicit role replaces the open role, so adding someone as viewer to an open project
restricts them. Workflow conditions can check roles and permissions (actor.hasRole("qa")).
API tokens and service accounts¶
Personal access tokens (wfm_pat_…, Profile → Tokens) act as their owner with a scope:
read (GET only), write (everything except administration) or admin (administration too;
org admins only). Tokens expire after 1–366 days and are stored hashed.
Service accounts (Administration → Users → New service account) are non-human users for CI and scripts: give them project roles like people, and issue tokens for them. They cannot sign in interactively.
Access tokens from your identity provider¶
Scripts and services that already get tokens from your identity provider can call the API with them instead of a personal access token. Give the provider an API audience, different from its sign-in client id so that sign-in ID tokens can never be replayed against the API:
WFM_OIDC_KEYCLOAK_API_AUDIENCE=workflow-api
WFM_OIDC_KEYCLOAK_API_CLIENTS=ci-pipeline=ci-bot@service.example # optional: client id=service account
- The token must be signed by that provider, have the API audience, be unexpired, and not be an
ID or logout token (
typIDorLogout). - Its
scopeclaim sets what it may do:wfm:read,wfm:writeandwfm:adminwork like theread,writeandadmintoken scopes; a token with none of them is refused. - A token for a client listed in
API_CLIENTS(client-credentials) acts as that service account (its username, which is the email it was created with). Any other token acts as the person whose subject (sub) has signed in with that provider before; deactivated people are refused. - Like personal access tokens, these never count as a browser session: no CSRF header is needed,
and they cannot create tokens, change sign-in settings or approve connected apps. They do not
work for
/mcp, which takes personal access tokens or its own OAuth sign-in.
In Keycloak, add an Audience mapper for workflow-api to the calling client (or a client
scope) and client scopes named wfm:read, wfm:write and wfm:admin.
Every security-relevant action — sign-ins, provisioning, role and membership changes, tokens,
workflow publishes, connections — is recorded in the audit log (Administration → Audit log,
GET /api/v1/admin/audit).