Skip to content

Sign-in

Sign-in is the console page from which an administrator configures how the firm’s people authenticate. LegalMemory ships with a Keycloak realm and drives it over Keycloak’s admin REST API on the administrator’s behalf: every action on this page reads or writes realm objects directly, so nobody opens Keycloak’s own console. The realm administrator password and every identity provider client secret stay in the app process; the browser only ever sees what it typed.

The page is visible to administrators only (role:admin). All endpoints below require it.

What each console action writes to the realm

Section titled “What each console action writes to the realm”

The appliance authenticates against the master realm (admin_realm) with a password grant on the admin-cli client (admin_client_id), using credentials read from the environment at call time (KI_KEYCLOAK_ADMIN_USERNAME / KI_KEYCLOAK_ADMIN_PASSWORD). It then calls /admin/realms/{realm}/… on the internal address (identity.admin_base_url). Every write is idempotent: re-running setup (a second administrator, a re-pasted rotated secret, a fresh stack against an existing realm) converges on the same realm instead of failing or duplicating.

Console actionRealm objects created or updated
Configure a providerAn identity-provider instance (providerId: oidc); two broker mappers on it; token-claim fixes on the clients listed below
Remove a providerDeletes the identity-provider instance
Add personA realm user (username = email, emailVerified, required action UPDATE_PASSWORD); a temporary password credential; a realm passwordPolicy if the realm had none
Reset passwordA new temporary password credential; re-asserts UPDATE_PASSWORD
Enable / disableThe user’s enabled flag
Delete personDeletes the realm user
Link (alias)Nothing in the realm; writes security.principal_aliases in the appliance’s own config

The identity provider client secret is written to Keycloak and additionally stored in the appliance database as AES-256-GCM ciphertext (under the same key as connector credentials, KI_CONNECTOR_CREDENTIAL_KEY). This is required because Keycloak’s admin API masks the secret on read, and a later Test sign-in must re-present it to the provider. No endpoint returns it.

Every configure, test, remove, person and alias action is recorded as an audit event (identity.provider.*, identity.person.*, identity.alias.*). The temporary password is never included in an audit event.

Four provider types are offered. All four are written into the realm as the same generic Keycloak oidc broker, with the endpoints filled from the provider’s own discovery document rather than from Keycloak’s branded social providers, so every configured value is traceable to something the administrator can check.

KindLabelExtra fieldDiscovery URL
googleGooglenonehttps://accounts.google.com/.well-known/openid-configuration (fixed)
entraMicrosoft (Entra)Directory (tenant) IDhttps://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
oktaOktaOkta domain (host only, no https://)https://{domain}/.well-known/openid-configuration
oidcOther OIDCDiscovery URL (must start with http:// or https://)as pasted

Every provider additionally asks for a client ID and a client secret. The secret input is write-only; replacing credentials means pasting the secret again. For entra and okta, a pasted full URL is stripped back to the host automatically.

POST /api/identity/providers performs these steps and stores nothing if any of them fails:

  1. Fetch and validate the discovery document. It must return HTTP 200, parse as JSON, and contain issuer, authorization_endpoint, token_endpoint and jwks_uri. This is the first proof that the tenant ID or Okta domain names a directory that exists. Failure → HTTP 400.
  2. Probe the client credentials against the provider’s token endpoint (see below). Rejection → HTTP 400; the broker is not written, because a broker that cannot log anybody in would still appear on the login page.
  3. Create or update the broker (POST/PUT /identity-provider/instances). The instance uses clientAuthMethod: client_secret_post, trustEmail: true (the provider verified the address; a second confirmation mail would strand every user), syncMode: FORCE, default scopes openid profile email, and the issuer, authorization, token, JWKS, userinfo and logout endpoints copied from the discovery document.
  4. Ensure two broker mappers on the instance:
    • username-from-email (oidc-username-idp-mapper, template ${CLAIM.email}), which forces the imported Keycloak username to be the email address the provider asserts. This is the join key: connectors normalise every person to user:<email>, and access is decided by matching that string. A broker that imports someone as ursula while a source reports ursula@firm.de produces no error and no documents.
    • email (oidc-user-attribute-idp-mapper) copies the email claim to the user’s email attribute.
  5. Ensure the token claims the appliance requires (next section).
  6. Store the credential row (client ID, encrypted secret, discovery URL, extra value, issuer) so a later test can re-verify against the provider.

Each provider card shows the redirect URI to register, including for providers nobody has configured yet; it is the value a firm must register at Google, Entra or Okta before it has a client ID and secret to paste back. It is derived exactly as:

{identity.public_base_url}/realms/{identity.realm}/broker/{alias}/endpoint

where alias is the provider kind (google, entra, okta, oidc). With defaults, the Google URI is http://localhost:8083/realms/knowledge-index/broker/google/endpoint. It must be registered at the provider character for character; a mismatch is the single most common reason a first login fails.

POST /api/identity/providers/{alias}/test re-establishes each fact rather than inferring it from what was saved: the discovery document is re-fetched, the stored secret is re-presented to the provider, the broker is re-read out of the realm, and a login is started the way a browser starts one. No browser session is created by any probe.

Check idWhat is actually doneA failure means
discoveryGET the stored discovery URL; validate JSON and the four required endpointsThe provider (or the pasted tenant/domain/URL) is unreachable, or the URL does not serve an OIDC discovery document
credentialsPOST an authorization_code grant with a code that cannot exist to the token endpoint, carrying the stored client ID and decrypted secret. Per RFC 6749 §5.2, invalid_client / unauthorized_client / HTTP 401 means the credentials were rejected; invalid_grant or invalid_request means the provider authenticated the client and only rejected the fake code, which is the passThe client ID or secret is wrong (revoked, rotated, mistyped)
realmRead the broker instance back out of the realm; require it present, enabled, and its configured issuer equal to the freshly fetched oneThe broker was deleted or disabled in Keycloak directly, or the realm points at a different issuer than the provider now reports
client:<id> (one per token_client_ids) and audienceThe token-claims checklist, re-read (next section)See next section
loginStart the authorization request at the realm’s own …/protocol/openid-connect/auth with kc_idp_hint=<alias>, then follow the redirect chain. Keycloak’s hops to its own canonical public URL are rewritten back to admin_base_url (the public name is unreachable from inside the compose network); the provider’s hops are followed untouched. Success = the chain lands on the provider’s authorization-endpoint origin and not on an error pageKeycloak stopped at its own broker page, the client is missing from the realm, or the provider refused; the reason is extracted from error_description / error in the redirect (Google’s opaque authError blob is decoded for its readable fragments, e.g. redirect_uri_mismatch). If the provider gave no reason, the usual cause is an unregistered redirect URI

The result is stored on the credential row (last_tested_at, last_test_ok, per-check detail) and shown on the card as tested / failing / untested. Provider response bodies are truncated to 300 characters before display, because they can contain tokens or assertions.

The “Token settings this appliance requires” panel asserts, on every save and on every page load, the realm plumbing that a working OIDC login turns out to depend on. Each item fails silently in Keycloak itself, which is why the console keeps them visible:

CheckAsserted onWhy it is load-bearing
Full (non-lightweight) access tokens + a sub mapperEach client in identity.token_client_ids (default knowledge-index-ui, knowledge-index-mcp)The identity resolver reads the subject from security.subject_claim (default sub) and refuses a token without one. A lightweight access token carries no sub; Keycloak 25 moved sub out of the token core into the basic client scope, so a realm without that scope never had one
An aud mapper stamping security.oidc_audienceThe client named by identity.audience_client_id (default knowledge-index-ui)Token validation requires the expected audience; a token with no aud fails audience validation and the appliance rejects it. If a default client scope already carries the mapper, nothing is added
Client present in the realmAll of the aboveA missing client is reported as “absent from the realm” and cannot be fixed from here

These checks cover what the appliance’s own writes control. The other two claims the resolver consumes come from the shipped realm’s client scopes and are matched against mirrored ACLs:

  • preferred_username (security.username_claim): produced by the profile scope; becomes the username:<value> principal.
  • groups (security.groups_claim): produced by the groups client scope (group-membership mapper, full.path: false); each entry becomes a group:<name> principal, matched against mirrored group ACLs, and membership in any of security.admin_groups mints role:admin.

The subject becomes user:<sub>; when the token carries a verified email claim, user:<email> and username:<email> are added as well, because mirrored source ACLs name people by email while sub is an opaque realm ID. A red check therefore means concretely: no sub → every login is refused with “OIDC token has no subject”; no aud → every token is rejected at validation; no groups → no group-based ACL matches and no administrator.

GET /api/identity/people joins two lists that nothing else puts side by side:

  • Realm users, read over the admin API (default cap 200), with their federated-identity links (which broker each person signs in through; an empty list means a local password account).
  • Identities the connectors mirrored: every user principal from source_group_members (directory membership) plus source_object_grants (per-object shares), casefolded and keyed by address. Only sources that report identities at all count as witnesses; a local folder mirrors no directory and is never counted against anybody.

Each row shows: username, last seen (recovered from the most recent audit events naming that principal), sign-in route (broker aliases, or “password”), and source match, matched in n of m witnessing sources. Aliases already configured in security.principal_aliases are applied before matching, so a bridged person reads as matched. Rows are sorted with the fewest matches first.

“Will see nothing” detection: when at least one source reports identities and a person matches none of them, the row is flagged and names the sources that do not know the address. This is the failure the table exists to surface: such an account works perfectly, raises no error anywhere, and opens onto an empty index. The Add person form runs the same check while the administrator is still typing the address, and the create response repeats it.

ActionEndpointBehaviour
List peopleGET /api/identity/peopleThe join described above; also returns the mirrored-identity index and the alias map
Add personPOST /api/identity/peopleValidates the email (must be a real address, since it is the join key), sets a minimum realm password policy if the realm has none (length(12) and notUsername), creates the user with username = email and emailVerified, generates a 20-character temporary password (four blocks of five, look-alike characters removed), sets it as temporary with UPDATE_PASSWORD required. 409 if the address can already sign in
Reset passwordPOST /api/identity/people/{id}/passwordIssues a new temporary password; the old one stops working immediately; the first-sign-in change is re-armed. Offered in the console for password accounts only
Enable / disablePOST /api/identity/people/{id}/enabledDisable stops the account opening but keeps it and its history; refused (400) for the caller’s own account
DeleteDELETE /api/identity/people/{id}Removes the realm user permanently; refused for the caller’s own account. Disabling is the reversible option
Link aliasPOST /api/identity/aliasesBody {principal, alias} as user:<address> pairs; bridges a sign-in identity onto the identity a source reported for the same person. Additive only: an alias adds principals, never denies, so the worst a wrong entry does is fail to match. Stored in security.principal_aliases, not in the realm
Remove aliasDELETE /api/identity/aliases?principal=…Removes the bridge

Temporary passwords are shown exactly once, in the browser, and are never stored, logged, or retrievable; a reset issues a new one. Local password accounts exist for firms with no directory; when a broker is configured the form says so and recommends it instead.

The self-guard (is_self) matches the realm account’s id, username and email against every name the caller holds (the OIDC subject, the username, and the user: / username: principals), because behind an OIDC login the subject is the Keycloak user ID while behind a trusted proxy it is the asserted address. It is enforced by the endpoints, not just hidden in the UI.

Two ways into the appliance, selected by security.auth_mode:

  • Through oauth2-proxy (compose port 8090, the production-style entry). oauth2-proxy performs the OIDC login against the realm’s knowledge-index-ui client and forwards the result upstream as headers. In the default trusted_header mode the resolver, finding no x-ki-principals header, reads oauth2-proxy’s headers instead, preferring the verified email (x-auth-request-email, x-forwarded-email) over the opaque OIDC user ID, then preferred_username, then the user header, and turns x-auth-request-groups into group: principals (Keycloak’s leading / on group paths is stripped). If security.trusted_header_secret is set, the request must also carry a matching x-ki-proxy-secret header; this pins header trust to the proxy.
  • Direct to the app port (compose port 8000). In trusted_header mode a caller may name its own principals in x-ki-principals (security.trusted_header_name), the development identity gate. The console participates: the UI attaches that header from localStorage["ki.devPrincipals"] or the VITE_DEV_PRINCIPALS build variable. Anyone who can reach the port becomes anyone, so this mode is for development or for deployments where only the proxy can reach the port. In oidc mode the API instead requires a bearer token, validated by signature (RS256/ES256) against security.jwks_url, with issuer security.oidc_issuer and audience security.oidc_audience both enforced (an empty audience is refused outright rather than accepting any client’s token).

Where role:admin comes from: in both modes, the resolver adds role:authenticated to every successful login and role:admin when the caller’s groups intersect security.admin_groups (default knowledge-index-admins). Nothing else mints it. The Sign-in page, and every admin endpoint, keys on it.

MCP is different: /mcp always requires a bearer token regardless of auth_mode, because trusted_header is a statement about a proxy in front of the admin UI and no proxy stands in front of an MCP client. The development escape hatch security.mcp_allow_trusted_header (default false) can relax this and must never be on for a firm’s appliance. The full OAuth resource-server flow (the 401 challenge, protected-resource metadata, audience binding to the MCP resource) is documented in Deployment & identity.

Env vars follow the KI_<SECTION>__<FIELD> convention.

Config keyEnv varDefaultEffect
identity.admin_base_urlKI_IDENTITY__ADMIN_BASE_URLhttp://keycloak:8080Where this appliance reaches Keycloak, inside the container network. All admin API calls and the test login’s probe go here
identity.public_base_urlKI_IDENTITY__PUBLIC_BASE_URLhttp://localhost:8083Where browsers and identity providers reach Keycloak. The broker redirect URI is derived from it, and the test login rewrites Keycloak’s public-URL redirect hops back to the internal address
identity.realmKI_IDENTITY__REALMknowledge-indexThe realm this page manages
identity.admin_realmKI_IDENTITY__ADMIN_REALMmasterRealm the appliance’s admin credentials authenticate against
identity.admin_client_idKI_IDENTITY__ADMIN_CLIENT_IDadmin-cliClient used for the admin password grant
identity.audience_client_idKI_IDENTITY__AUDIENCE_CLIENT_IDknowledge-index-uiThe client whose tokens the appliance validates; the audience mapper is asserted here, and the test login is started as this client
identity.token_client_idsKI_IDENTITY__TOKEN_CLIENT_IDS["knowledge-index-ui", "knowledge-index-mcp"]Every client that mints a token for a person; each is asserted to issue full access tokens carrying sub
identity.admin_username_envn/aKI_KEYCLOAK_ADMIN_USERNAMEName of the env var the admin username is read from, at call time
identity.admin_password_envn/aKI_KEYCLOAK_ADMIN_PASSWORDName of the env var the admin password is read from. Neither credential is ever written to config.json

Both base URLs exist because they name the same server from two networks: the appliance talks to Keycloak on the compose network, while the redirect URI a firm registers at Google or Entra must carry the published name. KC_HOSTNAME on the Keycloak container pins one canonical public URL so the broker callback does not vary by caller.

Config keyEnv varDefaultEffect
security.auth_modeKI_SECURITY__AUTH_MODEtrusted_headertrusted_header or oidc, as described above
security.oidc_issuerKI_SECURITY__OIDC_ISSUERhttp://keycloak:8080/realms/knowledge-indexMust equal the token’s iss. Tokens are minted for whoever asked, so this is usually the public issuer name
security.oidc_jwks_urlKI_SECURITY__OIDC_JWKS_URLempty (derived from issuer)Where signing keys are fetched. Separate from the issuer because the appliance fetches keys on the container network while iss carries the public name
security.oidc_audienceKI_SECURITY__OIDC_AUDIENCEknowledge-indexRequired in every API token’s aud; the checklist asserts a mapper stamps it
security.subject_claimKI_SECURITY__SUBJECT_CLAIMsubClaim that becomes the subject and the user:<subject> principal
security.username_claimKI_SECURITY__USERNAME_CLAIMpreferred_usernameClaim that becomes the username and the username:<value> principal
security.groups_claimKI_SECURITY__GROUPS_CLAIMgroupsClaim whose entries become group:<name> principals
security.admin_groupsKI_SECURITY__ADMIN_GROUPS["knowledge-index-admins"]Membership in any of these mints role:admin
security.trusted_header_nameKI_SECURITY__TRUSTED_HEADER_NAMEx-ki-principalsHeader a caller may use to assert principals in trusted_header mode
security.trusted_header_secretKI_SECURITY__TRUSTED_HEADER_SECRETunsetWhen set, requests must carry it in x-ki-proxy-secret; pins header trust to the proxy
security.principal_aliasesn/a (written by the console){}The alias bridges managed by the Link action
security.mcp_allow_trusted_headerKI_SECURITY__MCP_ALLOW_TRUSTED_HEADERfalseDevelopment-only: lets x-ki-principals authenticate MCP calls

deploy/keycloak/knowledge-index-realm.json is imported on first start (start-dev --import-realm) and contains, for development:

  • Groups: knowledge-index-admins, knowledge-index-users, ma-team, litigation.
  • Users: admin@example.com (in knowledge-index-admins) plus three sample users, all with the documented dev password lm-dev-only.
  • Clients:
    • knowledge-index-ui: confidential client oauth2-proxy signs in with (dev secret lm-dev-only); redirect URIs for the :8090/oauth2/callback entry; lightweight access tokens disabled; default scopes include groups and knowledge-index-api.
    • knowledge-index: bearer-only client that exists as the API’s audience.
    • knowledge-index-mcp: public client with PKCE (S256) for MCP clients that do not self-register; redirect URIs for local MCP tooling and claude.ai / claude.com callbacks.
  • Client scopes: basic (carries sub, which Keycloak 25 moved here, and auth_time), profile (preferred_username, full name), email, groups (group-membership claim, names without the leading /), knowledge-index-api (stamps aud: knowledge-index), and knowledge-index-mcp, the scope that binds an access token to the MCP endpoint by stamping the resource URL (http://localhost:8000/mcp in dev) into aud, with a consent-screen entry. It is a realm-default optional scope, so any dynamically registered MCP client may request it.
  • Anonymous client-registration policy: registrant URIs must match, trusted hosts limited to localhost, 127.0.0.1, claude.ai, claude.com, opencode.ai; consent required; full scope disabled; at most 200 clients.

How MCP clients use this (dynamic registration, the RFC 9728 metadata, the audience check) is covered in Deployment & identity, not here.

SymptomCause and behaviour
“Cannot reach the realm” banner; provider and people lists degradeKeycloak unreachable at identity.admin_base_url. Reads report the error inline (realm_error); writes fail with 502/503. Error text names the address, never a secret
503 naming KI_KEYCLOAK_ADMIN_USERNAME / KI_KEYCLOAK_ADMIN_PASSWORDThe admin credentials are not set in the deployment environment, so the appliance cannot configure the realm at all
“Keycloak refused the appliance’s administrator credentials (401)”Wrong admin username or password. The response body from Keycloak is deliberately not echoed, since a failed password grant can name the account
Save rejected with “…rejected the client id or secret”The credentials probe failed at the provider’s token endpoint; nothing was written to the realm or the database
Test sign-in: login check fails with redirect_uri_mismatch (or a provider error at its own error page)The redirect URI registered at the provider does not match {public_base_url}/realms/{realm}/broker/{alias}/endpoint exactly. When the provider gives no reason in the redirect, an unregistered redirect URI is the stated usual cause
Test sign-in: “Keycloak stopped at its own broker page”The realm-side broker chain is broken (alias, client, or imported endpoints), before the provider is ever reached
Token-claims check redSee the checklist: missing sub refuses every login, missing aud rejects every token, and a client “absent from the realm” cannot be repaired from the console
Person signs in fine but sees nothingNo source reports their address. The People table flags the row; fix at the source, or use Link if the person exists there under a different address