{"id":51175296,"url":"https://github.com/thunderbird/keycloak-mfa-rest","last_synced_at":"2026-06-27T03:05:15.676Z","repository":{"id":363424504,"uuid":"1263254028","full_name":"thunderbird/keycloak-mfa-rest","owner":"thunderbird","description":"Keycloak REST extension for TOTP enrollment and recovery-code generation (used by Thunderbird Accounts).","archived":false,"fork":false,"pushed_at":"2026-06-08T20:47:45.000Z","size":13,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-08T22:22:04.865Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thunderbird.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-08T19:19:17.000Z","updated_at":"2026-06-08T19:19:27.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thunderbird/keycloak-mfa-rest","commit_stats":null,"previous_names":["thunderbird/keycloak-mfa-rest"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/thunderbird/keycloak-mfa-rest","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fkeycloak-mfa-rest","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fkeycloak-mfa-rest/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fkeycloak-mfa-rest/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fkeycloak-mfa-rest/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thunderbird","download_url":"https://codeload.github.com/thunderbird/keycloak-mfa-rest/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fkeycloak-mfa-rest/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34839928,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-27T03:05:15.022Z","updated_at":"2026-06-27T03:05:15.666Z","avatar_url":"https://github.com/thunderbird.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# keycloak-mfa-rest\n\nA small Keycloak `RealmResourceProvider` that lets a **logged-in user self-manage their\nown MFA** — enrol TOTP and (re)generate recovery codes — driven by a front-end app\n(thunderbird-accounts) that forwards the user's OIDC access token, **without** the browser\nlogin flow. Every operation acts on the token's subject, so a caller can only ever manage\ntheir own account.\n\nAll secret generation, validation, and hashing is delegated to Keycloak's own\ncredential providers (`HmacOTP`, `CredentialHelper`, `OTPCredentialModel`,\n`RecoveryAuthnCodesUtils`, `RecoveryAuthnCodesCredentialProvider`). This module\ncontains **no** hand-rolled cryptography.\n\n## Endpoints\n\nAll endpoints are mounted at `/realms/{realm}/mfa`. See [Authorization](#authorization)\nfor who may call them.\n\nThere is intentionally **no `userId` path parameter** — each endpoint operates on the\nsubject of the bearer token.\n\n| Method | Path | Body | Returns |\n| ------ | ---- | ---- | ------- |\n| `GET`  | `/totp/setup` | – | `{ secret, encodedSecret, otpAuthUri, qrCode, digits, period, algorithm }` |\n| `POST` | `/totp/register` | `{ secret, code, deviceName? }` | `{ success, label }` |\n| `POST` | `/recovery-codes/regenerate` | `{ deviceName? }` | `{ codes, total, remaining }` |\n\nNotes:\n\n- `setup` generates a secret and returns it; the caller holds it (transiently) and\n  passes it back to `register` along with the user-entered `code`. The credential is\n  only written after the code validates.\n- `register` is **enrollment-only**: it validates the code via\n  `CredentialValidation.validOTP` before writing, and returns\n  `409 { \"error\": \"mfa_already_configured\" }` when a TOTP credential already exists.\n  There is deliberately no overwrite/replace — a bearer token alone must never be able\n  to swap the user's authenticator for another one. Re-enrollment (\"I got a new phone\")\n  is remove (a privileged, step-up-gated operation in the consuming app, via the Admin\n  API) followed by a fresh register.\n- `recovery-codes/regenerate` is single-step: it replaces any existing recovery-codes\n  credential and returns the new plaintext codes **once**. The \"this resets your old\n  codes\" confirmation lives in the calling UI. Recovery codes are strictly a **backup\n  for the authenticator app**, never a standalone factor: the endpoint returns\n  `409 { \"error\": \"totp_not_configured\" }` when the user has no TOTP credential.\n  (Keycloak silently deletes the recovery-codes credential once the last code is spent\n  at login, so a codes-only account would be a few logins away from having no second\n  factor at all.) The endpoint also requires step-up\n  (see [Step-up](#step-up-on-sensitive-mutations)).\n- `deviceName` is stored as the credential's user label and is capped at 255 characters\n  (Keycloak's column size); longer values return `400 { \"error\": \"invalid_device_name\" }`.\n\n## Authorization\n\nThe model is **user self-service**, not admin-on-behalf:\n\n1. **Bearer token required** — the caller forwards the end user's OIDC access token. No\n   token → `401`.\n2. **Subject-scoped** — the resource acts only on `token.sub`; there is no id to spoof, so\n   a caller can never touch another user's MFA.\n3. **Fail-closed client allowlist** — the token's `azp` must be in the configured\n   `authorized-clients` allowlist. The allowlist is **required**: when it is empty the\n   resource denies everything (`403`). This pins access to the front-end app's OIDC client\n   whose user tokens are forwarded here.\n\nFailures return `401 { \"error\": \"not_authenticated\" }` (no token / token without a user),\nwith a `WWW-Authenticate: Bearer` challenge, or `403 { \"error\": \"client_not_authorized\" }`\n(client not allowlisted).\n\n### Step-up on sensitive mutations\n\n`recovery-codes/regenerate` requires the token to **prove a recent\nsecond-factor authentication** (token-claims step-up, in the spirit of RFC 9470):\n\n- the token's `acr` claim must equal `step-up-acr` (default `\"2\"`, the realm's\n  second-factor level), **and**\n- the token's `auth_time` claim must be no older than `step-up-max-age-seconds`.\n\nWhen the check fails the endpoint returns `401 { \"error\": \"step_up_required\" }` with a\n`WWW-Authenticate: Bearer error=\"insufficient_user_authentication\", acr_values=\"...\",\nmax_age=...` challenge (RFC 9470). The consuming app should send the user through\nKeycloak's login flow with `acr_values=\u003cstep-up-acr\u003e` and `max_age=0` (forcing an\nactive re-authentication so `auth_time` is refreshed — otherwise a stale stepped-up\nsession satisfies `acr` but not the freshness check and the redirect loops), then retry\nwith the fresh token.\n\nA TOTP credential always exists when the gate runs (without one, regenerate is rejected\nwith `409 totp_not_configured`). The gate is skipped in exactly one situation:\n**immediately after TOTP enrollment**, when the user's TOTP credential was registered\nwithin the step-up window. Registering it required presenting a valid live OTP code,\nwhich is exactly the proof a step-up would demand, so the common\nenrol-authenticator-then-save-recovery-codes flow doesn't prompt twice.\n\nResidual risks to be aware of: a token replayed *within* `step-up-max-age-seconds` of a\ngenuine second-factor authentication still passes — the window is bounded but non-zero.\nAnd first-time TOTP enrollment on an account with no MFA cannot be acr-gated (there is\nno factor to challenge), so that surface is protected only by proving the new code.\n\nSecret-bearing responses (`setup`, `recovery-codes/regenerate`) are returned with\n`Cache-Control: no-store` and `Pragma: no-cache`. Credential mutations emit Keycloak\n`UPDATE_CREDENTIAL` events (success and invalid-code failure) recording the user, the\nacting client, and the source IP — enable realm event logging for an audit trail.\n\nAdministrative reset of *another* user's MFA is deliberately out of scope for this\nresource; do that in a privileged context (e.g. an admin panel using the Keycloak Admin\nAPI or a separate, role-gated endpoint).\n\n## Configuration\n\n| Option | Config key / env | Default | Purpose |\n| ------ | ---------------- | ------- | ------- |\n| Authorized clients | `spi-realm-restapi-extension-mfa-authorized-clients` / `KC_SPI_REALM_RESTAPI_EXTENSION_MFA_AUTHORIZED_CLIENTS` | _(unset → deny all)_ | **Required.** Comma-separated client IDs whose user tokens (matched on `azp`) may self-manage MFA — typically the front-end app's OIDC client. |\n| Step-up ACR | `spi-realm-restapi-extension-mfa-step-up-acr` / `KC_SPI_REALM_RESTAPI_EXTENSION_MFA_STEP_UP_ACR` | `2` | ACR claim value a token must carry for step-up-gated mutations — the realm's second-factor level of authentication. |\n| Step-up max age | `spi-realm-restapi-extension-mfa-step-up-max-age-seconds` / `KC_SPI_REALM_RESTAPI_EXTENSION_MFA_STEP_UP_MAX_AGE_SECONDS` | `600` | Maximum age (seconds) of the token's `auth_time` claim for the step-up to count as recent. Keep aligned with the consuming app's recent-auth window (thunderbird-accounts `MFA_RECENT_AUTH_SECONDS`) so the two layers expire together. |\n\n## Building\n\nBuilding happens in CI (see `.github/workflows/ci.yml`). For a local build, use a\nthrowaway container — only Docker is required, no JDK or Maven on your machine:\n\n```bash\ngit clone https://github.com/thunderbird/keycloak-mfa-rest.git\ncd keycloak-mfa-rest\n\ndocker run --rm -v \"$PWD\":/app -w /app maven:3.9-eclipse-temurin-17 mvn -B verify\n```\n\n`mvn -B verify` compiles, runs the unit tests, and packages the module; the result is\n`target/keycloak-mfa-rest.jar`. (`-B` is Maven's non-interactive batch mode.)\n\nNotes:\n\n- The `maven` image runs as root, so `target/` is created root-owned. Clean it with\n  `mvn -B clean` in the same container, or `sudo rm -rf target`.\n- Each `--rm` run re-downloads dependencies. To cache them across builds, mount a\n  local repository (already gitignored as `.m2repo/`):\n\n  ```bash\n  docker run --rm -v \"$PWD\":/app -v \"$PWD/.m2repo\":/root/.m2 -w /app \\\n      maven:3.9-eclipse-temurin-17 mvn -B verify\n  ```\n\n## Releasing\n\nCI runs on two triggers (see `.github/workflows/ci.yml`): pull requests to `main` run\nbuild + test (the merge gate), and pushing a `v*` tag runs build + test and then publishes\na GitHub Release. There are no automatic releases on merge — cutting a release is an\nexplicit, tagged action.\n\nTo cut a release:\n\n1. Bump `\u003cversion\u003e` in `pom.xml` (on its own PR, or just before tagging) and merge to `main`.\n2. Tag that commit and push the tag:\n\n   ```bash\n   git tag v0.1.0\n   git push origin v0.1.0\n   ```\n\n3. CI builds the jar and publishes Release `v0.1.0` with `keycloak-mfa-rest.jar` and\n   `keycloak-mfa-rest.jar.sha256` attached.\n4. Point the consumer at it (see [Deploying into Keycloak](#deploying-into-keycloak)):\n   set the pinned version and `sha256` from the release.\n\n## Deploying into Keycloak\n\nConsumers (e.g. thunderbird-accounts `Dockerfile.keycloak`) download the pinned,\nchecksummed jar into `/opt/keycloak/providers/` and run `kc.sh build`:\n\n```dockerfile\nADD --checksum=sha256:\u003csha\u003e \\\n    https://github.com/thunderbird/keycloak-mfa-rest/releases/download/v\u003cver\u003e/keycloak-mfa-rest.jar \\\n    /opt/keycloak/providers/\nRUN /opt/keycloak/bin/kc.sh build\n```\n\n## Realm requirements\n\n- The recovery-codes feature must be available (supported and enabled by default in\n  Keycloak 26.3+).\n- Set the `authorized-clients` allowlist (see [Configuration](#configuration)) to the\n  front-end app's OIDC client ID — the client whose user tokens are forwarded here. This\n  is required; without it the resource denies all requests.\n- The forwarded token must be a normal user access token from that client (its `azp` is the\n  client ID).\n- Recommended: enable realm event logging so credential-mutation events are persisted.\n\n## Keycloak version\n\nTargets the Keycloak version in `pom.xml` (`keycloak.version`). Keep it aligned with\nthe Keycloak image tag used by the consuming deployment.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthunderbird%2Fkeycloak-mfa-rest","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthunderbird%2Fkeycloak-mfa-rest","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthunderbird%2Fkeycloak-mfa-rest/lists"}