CodamAIDocs
Topicdone

The Keycloak extension for the password link

A jar that runs in Keycloak and returns the signed link instead of emailing it itself. What happens without the extension.

Variants
extension installed: linkextension missing: no linkaccount unknownrequest refusedclient not permittedin-memory adapter instead of KeycloakKeycloak started with --optimized

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

Request
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
}
Response
HTTP 200
{
  "link": "https://sso.example.com/realms/codamai/login-actions/action-token?key=…",
  "expiresAt": "2026-09-23T09:14:03Z"
}
FieldMeaning
userIdthe account in Keycloak
actionsthe required actions the link leads through, in this order
clientIdthe client of the user interface the person logs in to afterwards
redirectUriwhere to go after the password; must fit this client
lifespanvalidity in seconds, at most 72 hours; if missing, the realm’s setting for links triggered by an administrator applies, likewise capped at 72 hours
linkthe one-time link
expiresAtwhen 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:

From the request to the link
  1. Keycloak
    Login
    Valid token for the admin API?
    ↳ no 401
  2. Keycloak
    Caller
    Is the client the token was issued to named in the server option allowed-clients?
    ↳ no 403, before anything else is checked
  3. Keycloak
    Required fields
    Are the account and at least one action given?
    ↳ no 400
  4. Keycloak
    Account
    Does the account exist in this realm?
    ↳ no 404
  5. Keycloak
    Permission
    May the caller manage this account? The same permission as for Keycloak's own email.
    ↳ no 403
  6. Keycloak
    Account usable
    Does the account have an email address, and is it enabled?
    ↳ no 400
  7. Keycloak
    Actions
    Is every action permitted (UPDATE_PASSWORD, UPDATE_PROFILE, TERMS_AND_CONDITIONS), is UPDATE_PASSWORD among them, and is every action switched on in the realm?
    ↳ no 400, with the affected actions in the text
  8. Keycloak
    Client
    Does the client exist, and is it switched on?
    ↳ no 400
  9. Keycloak
    Return address
    Is an address given, and does it fit the allowed addresses of this client?
    ↳ no 400
  10. Keycloak
    Validity
    Is the validity positive and at most 72 hours, if given?
    ↳ no 400
  11. 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.

Two places ask for a link:

SettingMeaning
codamai.cias.registration.password-setup.client-idthe 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-forhow 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-actionswhich 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

Is there a link?

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

Does the welcome email contain a link?
password-setup.client-id set?New account?Extension loaded?Checks passed?Email
no–––without a link, with a hint to "forgot password"
yesno––without a link: whoever already has an account has a password
yesyesno–without a link
yesyesyesnowithout a link, warning in the log
yesyesyesyeswith 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.

How the jar gets to Keycloak

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
EntryMeaning
cias-adminthe 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-clithe 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.

Pitfalls

Next

Sources in the code and the knowledge base
  • CIAS/cias-iam-keycloak-provider – ActionLinkResourceProviderFactory (ID cias-action-links), ActionLinkResourceProvider, ActionLinkResource (create, requireAllowedCaller, validActions, PERMITTED_ACTIONS, client, redirectUri, lifespan, MAX_LIFESPAN_SECONDS), AllowedCallers (allowed-clients), META-INF/services, Dockerfile, deploy/install-provider.sh, deploy/keycloak-sidecar.yaml, deploy/smoke-test.sh, README.md, CLAUDE.md
  • CIAS/cias-iam-keycloak – KeycloakIdentityAdapter (ACTION_LINKS, PERMITTED_PASSWORD_SETUP_ACTIONS, createPasswordSetupLink, seconds), KeycloakAdminApi.post, KeycloakPasswordSetupLinkTest, KeycloakTestEnvironment
  • CIAS/cias-iam-api – IdentityProvisioningPort.createPasswordSetupLink, PasswordSetupLink (MAX_VALIDITY, requireValidity)
  • CIAS/cias-iam-memory – InMemoryIdentityProvider.createPasswordSetupLink
  • CIAS/cias-registration – RegistrationService.passwordSetupContext, PasswordSetupPolicy; CIAS/cias-user – UserService.sendPasswordSetupLink
  • CIAS/cias-spring-boot-starter – CiasProperties (registration.password-setup.client-id, valid-for; keycloak.password-setup-actions), CiasAutoConfiguration.passwordSetupPolicy
  • CIAS/cias-runtime – docker-compose.yml (providers mount)
Search