What this is about
An account that CIAS creates has no password. CIAS never accepts one. The person is meant to set it themselves at Keycloak, on Keycloak’s page.
But how do they get there? The required action “set password” (UPDATE_PASSWORD) alone does not help. Keycloak only asks for required actions after login, and without a password nobody can log in. Only “forgot password” would be left.
Keycloak can issue a one-time link that solves exactly this problem. It is valid once, expires, and belongs to one account, one list of actions, one client and one return address. Keycloak signs it with the realm’s key, so nobody outside Keycloak can build it, not even CIAS. But Keycloak’s own admin API only delivers this link in an email that Keycloak sends itself, with Keycloak’s text and Keycloak’s sender.
The extension is not an adapter. The adapter (cias-iam-keycloak) runs in CIAS and calls the extension over HTTP. The extension is the other end of that call, in Keycloak’s server.
The flow
sequenceDiagram
participant C as CIAS
participant K as Keycloak
participant X as Extension in Keycloak
participant M as Email
participant B as User
C->>K: enable account, require UPDATE_PASSWORD
C->>X: POST /admin/realms/{realm}/cias-action-links
X->>X: checks caller, account, actions, client, return address
X-->>C: link and expiry time
C->>M: email with the link, in CIAS' own template
M-->>B: email
B->>K: opens the link
K-->>B: page "New password"
B->>K: set password
K-->>B: on to the return address, that is, the application's login
CIAS never sees a password in this. It only carries the link to the person, just as it carries the confirmation link of a registration.
Request and response
POST /admin/realms/codamai/cias-action-links
Authorization: Bearer <token of the service account cias-admin>
{
"userId": "5c9e…",
"actions": ["UPDATE_PASSWORD"],
"clientId": "hub-frontend",
"redirectUri": "https://app.example.com/login",
"lifespan": 86400
}HTTP 200
{
"link": "https://sso.example.com/realms/codamai/login-actions/action-token?key=…",
"expiresAt": "2026-09-23T09:14:03Z"
}| Field | Meaning |
|---|---|
userId | the account in Keycloak |
actions | the required actions the link leads through, in this order |
clientId | the client of the user interface the person logs in to afterwards |
redirectUri | where to go after the password; must fit this client |
lifespan | validity in seconds, at most 72 hours; if missing, the realm’s setting for links triggered by an administrator applies, likewise capped at 72 hours |
link | the one-time link |
expiresAt | when it expires |
What the extension checks
The extension sits under /admin/realms/{realm}. So Keycloak checks the caller’s token before its code runs at all. After that, it checks by itself:
-
KeycloakLoginValid token for the admin API?↳ no 401
-
KeycloakCallerIs the client the token was issued to named in the server option
allowed-clients?↳ no 403, before anything else is checked -
KeycloakRequired fieldsAre the account and at least one action given?↳ no 400
-
KeycloakAccountDoes the account exist in this realm?↳ no 404
-
KeycloakPermissionMay the caller manage this account? The same permission as for Keycloak's own email.↳ no 403
-
KeycloakAccount usableDoes the account have an email address, and is it enabled?↳ no 400
-
KeycloakActionsIs every action permitted (
UPDATE_PASSWORD,UPDATE_PROFILE,TERMS_AND_CONDITIONS), isUPDATE_PASSWORDamong them, and is every action switched on in the realm?↳ no 400, with the affected actions in the text -
KeycloakClientDoes the client exist, and is it switched on?↳ no 400
-
KeycloakReturn addressIs an address given, and does it fit the allowed addresses of this client?↳ no 400
-
KeycloakValidityIs the validity positive and at most 72 hours, if given?↳ no 400
- The link is built; Keycloak's admin event records account, actions, client and validity, but never the link
Every check has a reason. Unlike Keycloak’s own email, the link goes to the caller, not to the person’s mailbox. Without the list of callers, anybody allowed to manage accounts (manage-users) could therefore pick up links for any account. Without the fixed list of actions, one could build a link that, for example, deletes an account (delete_account). The limit of 72 hours makes sure a link does not lie valid in a mailbox for weeks. Without checking the return address, an email from the platform address could lead to a foreign site. Without checking the actions, there would be links that get stuck in the middle of the login. An account that is not enabled could not log in after the password anyway. So the extension refuses loudly here instead of handing out a useless link.
What CIAS does with the link
Two places ask for a link:
- The welcome email of the registration, only for new accounts. See Set the password.
- “Resend the link” by an administrator. See Resend the password setup link.
| Setting | Meaning |
|---|---|
codamai.cias.registration.password-setup.client-id | the client of the user interface the registration link is issued for. Empty means: the registration asks for no link |
codamai.cias.registration.password-setup.valid-for | how long the link is valid, at most 72 hours; empty means: the realm’s setting, capped at 72 hours. A larger value prevents the start |
codamai.cias.keycloak.password-setup-actions | which actions the link demands; empty means only UPDATE_PASSWORD. Whoever also wants consent to terms of use (TERMS_AND_CONDITIONS) or a complete profile (UPDATE_PROFILE) lists them here, and the person does everything in one visit. Other actions are not permitted, and UPDATE_PASSWORD has to be in the list, because it replaces the default. Otherwise CIAS does not start |
The variants
When: The jar is in Keycloak's providers/ directory, Keycloak loaded it at startup, and the request passes all checks.
The extension returns link and expiry time. CIAS puts the link into the email as passwordUrl.
Result: one click to your own password
When: The jar is not installed.
Keycloak does not know the path cias-action-links and answers 404. The adapter reads that as "no link". The registration still completes. The welcome email leads to the login and explains "forgot password". The administrator call "resend the link", on the other hand, sends no email at all.
Result: detour via "forgot password"
When: Keycloak does not know the account.
The extension also answers 404. To the adapter this looks like a missing extension: no link.
Result: no link
When: One of the checks fails, for example a return address that does not fit the client, an action that is switched off in the realm, or a validity above 72 hours.
The extension answers 400 or 403. During registration, CIAS writes a warning to the log, without the link and without the address, and the email goes out without a link. With the administrator call, the call fails.
Result: no link, note in the log
When: The extension is loaded, but the client CIAS logs in with is not named in allowed-clients, for example because the setting was forgotten during installation.
The extension answers 403 before it checks anything else. Keycloak already writes "no client may request links" to its log at startup, and names the client with every refusal. CIAS behaves as with any refused request.
Result: no link, note in both logs
When: codamai.cias.iam-provider: memory
The in-memory adapter returns a link that starts with memory:// and leads nowhere. That way emails with a link can be tested. A validity above 72 hours it refuses, as Keycloak does.
Result: test link
When: Keycloak starts with --optimized and was not built together with the jar.
Keycloak then skips reading in new extensions. The jar is in the directory but is never loaded. The path answers 404, and everything behaves as without the extension, without an error message.
Result: no link
password-setup.client-id set? | New account? | Extension loaded? | Checks passed? | |
|---|---|---|---|---|
| no | – | – | – | without a link, with a hint to "forgot password" |
| yes | no | – | – | without a link: whoever already has an account has a password |
| yes | yes | no | – | without a link |
| yes | yes | yes | no | without a link, warning in the log |
| yes | yes | yes | yes | with a link |
Installing the extension
Keycloak loads extensions at startup from the directory /opt/keycloak/providers. The jar is called cias-iam-keycloak-provider.jar and registers itself under the service name cias-action-links.
When: You start the stack from cias-runtime/docker-compose.yml.
First build the jar (mvn install in cias-iam-keycloak-provider), then uncomment the prepared mount line in the compose file. Keycloak runs there with start-dev and loads the jar at startup.
Result: link testable locally
When: Keycloak runs in Kubernetes.
A small image with the jar runs as an init container in Keycloak's pod. It copies the jar into a shared directory and exits. Only then does Keycloak start and find the jar. The template for this is deploy/keycloak-sidecar.yaml.
Result: jar in place before Keycloak starts
The jar alone is not enough. Keycloak also has to know which clients may pick up links. Without this setting the extension refuses every request.
KC_SPI_ADMIN_REALM_RESTAPI_EXTENSION__CIAS_ACTION_LINKS__ALLOWED_CLIENTS=cias-admin
| Entry | Meaning |
|---|---|
cias-admin | the client of this name in the realm being administered. The normal case: CIAS logs in with a service account of the realm it administers |
master/admin-cli | the client of this name in exactly this realm, for a caller that logs in to a different realm than it administers |
Several entries are separated by a comma. An entry without a realm never matches a client of the same name in another realm, because client names are unique only within a realm. In cias-runtime/docker-compose.yml, cias-admin is already set; in the cluster, deploy/keycloak-sidecar.yaml sets the variable on the Keycloak container.
Whether the extension is really loaded cannot be seen from a call without login. The admin API checks the login before the path and answers 401 in both cases. Two ways are reliable: Keycloak’s startup log mentions ActionLinkResourceProviderFactory, or a logged-in call without a body answers 400 or 403 (extension present) instead of 404 (extension missing). The script deploy/smoke-test.sh requests a real link and then checks that a foreign return address, an unknown action, an action that is not permitted and a validity above 72 hours are refused. The client the script logs in with has to be named in allowed-clients for this.