Repository navigation
Identity Providers
Documentation for the image tag 1.0.0-rc1. See Versions for the documentation of a release.
Qertum does not learn each identity provider (IdP). It asks for a small contract and you configure the IdP to meet it. This page is the contract, the settings, the recipe for the one provider tested against a real instance (Keycloak), and the status of every other provider. Step-up (a recent second factor for the most sensitive operations) is the part of the contract that needs the most IdP work; sign-in alone needs only the first half.
Which providers are covered, and how far:
| Provider | Status |
|---|---|
| Keycloak 26.8 |
Tested against a real instance (Docker, Qertum.Tests/IdpInterop/): sign-in, roles, audience, acr and auth_time, step-up inside an SSO session. Recipe below. |
| mock-oauth2-server | Tested as a second, independent OIDC implementation. It emits any claim shape on request and is not an MFA provider; it proves Qertum's reading of the claims, not that a product can produce them. |
| Microsoft Entra ID, Okta, Auth0, Authentik, Zitadel, Authelia, Google Workspace / Cloud Identity | Not evaluated, untested. No recipe yet. The checklist under "Any other provider" is what each must satisfy; what a given product can or cannot emit is still to be found out against a real tenant. |
An access token (JWT) sent to the Qertum API must carry:
| Claim | Needed for | Rule |
|---|---|---|
iss |
always | Equals Identity:Authority exactly (see Pitfalls). |
aud |
always | Contains Identity:Audience (default qertum-api). |
| a name claim | always |
Identity:NameClaimType (default preferred_username). Becomes the acting identity in audit entries and the owner of a request. |
sub |
always | Stable per user. Key access is decided on it, so it must not be recycled between people. |
| role values | RBAC |
Identity:RoleClaimType (default role). Repeated claims or a JSON array; a dotted path reaches into a JSON claim (realm_access.roles). |
scope |
always |
Identity:ScopeClaimType (default scope), repeated or space-delimited. |
acr |
step-up only | One of Identity:StepUp:AcrValues (default mfa, case-sensitive). |
auth_time |
step-up only | Unix seconds of the last interactive sign-in, at most Identity:StepUp:MaxAgeSeconds (default 300) old and not more than 30 s in the future. A numeric string is accepted. |
Qertum reads nothing else for step-up: no amr, no vendor claim. A missing, malformed, stale or future-dated value is refused.
The IdP must also honour acr_values and max_age on the authorization request (and, for qca, on the device
authorization request), so the Web UI and the CLI can step a user up when the API asks.
Two clients and one machine-client shape. The client IDs and scopes below are fixed in Qertum's code; the IdP is configured to match them.
| Client | Flow | Fixed values |
|---|---|---|
| Web UI | authorization code with PKCE, public client (no secret) | client ID qertum-web; redirect <web-url>/signin-oidc; post-logout <web-url>/signout-callback-oidc; scopes openid profile email roles offline_access qertum-api
|
CLI qca
|
device authorization grant (RFC 8628), public client | client ID qca; scopes openid profile roles qertum-api offline_access
|
| Automation | client credentials | any client ID; the access token must satisfy the API settings above |
qertum-api is the scope the IdP maps to the audience qertum-api (or to your Identity:Audience). If the IdP does not issue
roles or offline_access scopes, the clients still ask for them; make sure the request is not rejected for an unknown scope.
| Where | Setting | Meaning |
|---|---|---|
| ApiService | Identity__Authority |
The issuer URL. Must equal iss. Required outside Development. |
| ApiService |
Identity__Audience, Identity__RequireHttpsMetadata
|
Expected aud; fetch metadata over HTTPS only (default true). |
| ApiService |
Identity__NameClaimType, Identity__RoleClaimType, Identity__ScopeClaimType
|
Claim names, as in the table above. |
| ApiService |
Identity__StepUp__Enabled, __AcrValues, __MaxAgeSeconds, __AcrClaimType, __AuthTimeClaimType
|
Step-up; off by default. |
| Web | IdentityService__Authority |
The issuer URL the browser is sent to. A separate setting from Identity__Authority; set both to the same value.
|
| CLI |
--identity-url or QERTUM_IDENTITY_URL
|
The issuer URL; qca reads the endpoints from its discovery document. |
Qertum has one built-in role name, admin (case-insensitive), which bypasses permission checks. Every other role value in the
token is a name an administrator grants permissions to under Roles in the Web UI (PUT /v1/permissions/{role}); the same name
must exist in the IdP. A user with no matching role holds no role-based permission. The Web UI re-reads what the API grants
(GET /v1/me/access) and does not trust its own cookie claims.
-
qca doctor --identity-url <issuer>: theidentitycheck probes the IdP. Fix anything that fails before testing step-up. -
qca auth login, thenqca doctor: thesessioncheck should pass and theapicheck, which calls the API with your token, should too. Sign in to the Web UI and check the menu matches your role. - Decode the access token and check
iss,aud, the role claim and (for step-up)acrandauth_timewith the values you expect before and after a second-factor sign-in. - Turn on
Identity__StepUp__Enabled=true, open a protected operation (for example a private-key download). With a fresh second-factor token it succeeds. With a password-only or old token the Web UI sends you to the IdP once more and returns. Over REST the API answers401withWWW-Authenticate: Bearer error="insufficient_user_authentication",acr_valuesandmax_age. If the Web UI does not loop and shows the API's explanation instead, the IdP could not satisfy the request: the claims are wrong or no second factor is set up for that user.
Tested on Keycloak 26.8.1. The whole recipe below is built by Qertum.Tests/IdpInterop/KeycloakFixture.cs through the admin
API; read it for the exact calls. In the admin console:
-
Realm: set the attribute
acr.loa.mapto{"pwd":"1","mfa":"2"}(Realm settings, General, orattributeson the realm). Set the same attribute on the client. Level 1 reportsacr: pwd, level 2 reportsacr: mfa. -
Browser flow: duplicate the built-in
browserflow, or createstepup-browser:-
Cookie: Alternative. - Subflow
stepup-forms: Alternative, holding two Conditional subflows:-
level1:Condition - Level of Authentication(Required) with level1, Store LoA in user session on, Max age36000; thenUsername Password Form(Required). -
level2:Condition - Level of Authentication(Required) with level2, Store LoA in user session on, Max age0; thenOTP Form(Required).
-
Max age
0on level 2 makes everyacr_values=mfarequest ask for the code again, which is what makesauth_timemove. Set the realm's Browser flow binding to it. -
-
Client
qertum-web: public, standard flow on, PKCES256, the redirect and post-logout URIs above. Add two mappers:-
Audience (
oidc-audience-mapper): included custom audienceqertum-api, add to access token. Without it the access token'saudisaccountand every call is401. -
User Realm Role (
oidc-usermodel-realm-role-mapper): token claim namerole, multivalued, add to access token and ID token. Create a realm roleadminand assign it. (Or leave the defaultrealm_access.rolesand setIdentity__RoleClaimType=realm_access.roles.)
-
Audience (
-
Client
qca: public, OAuth 2.0 Device Authorization Grant on, the same audience and role mappers, scopes as above. See the caveat below. -
Qertum:
Identity__Authority=https://<host>/realms/<realm>;Identity__StepUp__Enabled=true.
Result: a password-only sign-in gives acr: pwd; a request with acr_values=mfa forces the OTP page and gives acr: mfa;
both carry auth_time. Step-up inside an existing SSO session works and moves auth_time. max_age and prompt=login
re-authenticate and refresh it.
Not tested for Keycloak: the device flow with acr_values and max_age (so qca auth login --acr-values mfa --max-age 300
may sign in without asking for the second factor; check the resulting token), and the Web UI re-authentication round trip in a
real browser. The qca client has not been tried against Keycloak at all.
An automation client (client credentials) has no interactive sign-in and so no auth_time; its tokens fail a protected
operation unless the IdP is made to stamp both claims. Think before doing so: it turns the check into "holds the client
secret". A leaked secret would then pass step-up.
A static value does not work: a hardcoded auth_time goes stale after MaxAgeSeconds, and a hardcoded fresh one would be a lie.
The claim has to be computed at issue time, which needs a scripting hook in the IdP (an Auth0 Action, a Keycloak script or
custom mapper provider, an Entra claims-mapping or custom extension). This is untested on every provider. Usually the better
answer is to leave machine clients refused for these operations and have a person do them.
A provider is usable for sign-in if it can: publish an OIDC discovery document at <issuer>/.well-known/openid-configuration
whose issuer equals the iss of its tokens; issue JWT access tokens (opaque tokens cannot be validated locally); put the
audience, a name, roles and scopes in them; and serve a public PKCE client and a device grant.
It is usable for step-up only if it can also put acr and auth_time into the access token (not only the ID token),
change acr according to the acr_values of the request, force re-authentication on max_age, and ask for a second factor
when acr_values demands it. A provider that cannot is unsupported for step-up: leave Identity__StepUp__Enabled false and
rely on the provider's own conditional access for the sign-in. Two common stumbling blocks to check first: many providers
emit auth_time only in the ID token, and many report the second factor in amr (which Qertum ignores) rather than acr.
When you work one out, add its section here and state plainly whether it was tested against a real instance.
-
Issuer must match exactly.
Identity:Authorityis compared withissas a string: scheme, host, port, path and trailing slash all count (a trailing/in the setting is trimmed, one in the token is not). A mismatch gives401; the API log says which two strings differ. -
Host names inside Docker versus outside. The issuer is whatever the IdP is told it is reachable at. If the browser uses
https://idp.exampleand the API container reaches it ashttp://idp:8080, tokens still sayhttps://idp.example. Set the IdP's advertised host (Keycloak:KC_HOSTNAME) and make the API able to fetch the discovery document at that address. -
Two authority settings. The Web UI reads
IdentityService__Authority, the API readsIdentity__Authority. If they differ the Web UI signs in at one issuer and the API rejects tokens from it. -
Clock skew against
auth_time. A token whoseauth_timeis more than 30 s ahead of the API's clock is refused as future-dated. Keep the API host on NTP. -
Refresh tokens keep the old
auth_time. Refreshing does not re-authenticate; after a refresh a token still carries the sign-in time of the original login. Step-up works because the Web UI takes the new tokens from the new authorization response, not from a refresh. A client that only refreshes will keep being challenged. - One OTP code per window. TOTP codes are accepted once per 30 s window per credential, so two logins within one window fail at the IdP.
-
Keycloak specifics. It marks its cookies
Secureeven onhttp://localhost, so a scripted client over plain HTTP loses the session. OTP credentials can only be created throughpartialImport. The access token carriesaudonly through an audience mapper (the default isaccount). -
Revoked tokens stay valid until they expire. The API validates locally against the IdP's keys and does not introspect.
Keep access-token lifetimes short; this also bounds how long a
step-uptoken is usable.
Qertum
Deploy
Use
Operate
- Configuration Reference
- Backup and Restore
- Identity Providers
- Issuer Compromise Recovery
- Upgrading
- Troubleshooting
Releases