Changelog
v4.3.1 (2026-09-10)
The invite screen no longer shows a name field at all when the provider gave us one
- Apple rejected an integrating app a second time under Guideline 4, with wording identical to the first. Prefilling the Display Name (v4.2.0/v4.2.1) was not enough: a visible name field after Sign in with Apple reads as asking for the name whether or not it is pre-populated and optional.
- The field is now rendered only when the provider supplied nothing at all. Since
provider_namefalls back to the email local part, in practice it is no longer shown for any OAuth sign-in, andPOST /oauth/invite-acceptresolves the name fromprovider_profileas the normal path rather than the edge case. - No API change. Integrators offering Sign in with Apple on an invite-policy app should take this release.
v4.3.0 (2026-09-10)
The login page forwards the PKCE challenge and the device id
- Closes the gap left open in v4.1.0:
oauthLogin()on the login page droppedcode_challenge, so PKCE presented to/loginwas lost on the provider-button path, and thedevice_idwas not carried either. - Both are now forwarded, which means a native app using the provider buttons gets the device-scoped refresh-token family it asked for rather than sharing the unscoped one.
v4.2.1 (2026-09-08)
Fix: the invite name was still blank for a returning Sign in with Apple user
- v4.2.0 prefilled Display Name from
provider_profile["name"], which is correct on a first Sign in with Apple and useless on every one after it: Apple returns the name only on the first authorization of a Service ID. Testing on a simulator with an Apple ID that had signed in before producedname: '', so the field rendered empty again — for precisely the returning user the fix was meant to help. - Now falls back to the email local part when the provider supplies no name. This is the value
POST /oauth/invite-acceptalready stores when the field is submitted empty, so the screen shows what the account would get anyway rather than inventing a value. - Field remains optional, which is what Guideline 4 actually turns on; the prefill is about not presenting a returning user with an empty box that looks like a demand.
v4.2.0 (2026-09-08)
Fix: the OAuth invite screen re-asked for a name the provider had already given
- Apple App Review rejected an integrating app over this (Guideline 4, Sign in with Apple). After authenticating with Apple, a user landing on the invite-required screen was made to type their display name — but
AuthenticationServiceshad already supplied it, and Apple treats re-asking as a design violation. The same applied to every provider; Apple is simply the one that enforces it. - The value was never missing. The Apple callback puts it in
provider_profile["name"](falling back to"Apple User"for a private-relay sign-in that shares no name), and the open-registration path already used it to populateUser.display_name. Only the invite path failed to pass it to the template, so the field rendered empty. - Display Name is now prefilled from the provider and is no longer
required. It stays editable, because a provider placeholder should be correctable and because Apple objects to compulsory re-entry, not to an editable field. - No API change and nothing to do in your app.
POST /oauth/invite-acceptalready resolved a blankdisplay_nametoprovider_profile["name"], so the fallback was there before the field could exercise it. Existing integrations behave identically apart from the screen no longer blocking on a field the user should not have had to fill. - Affects every app with
registration_policy: "invite"that offers OAuth sign-in. If you ship an iOS app through Keymaster and offer Sign in with Apple, you were exposed to the same rejection.
v4.1.2 (2026-09-06)
Documentation only — no API or behaviour change.
Retracted: "web clients don't need a device_id"
- The Token Lifecycle guide told integrators that a browser needs no
device_idbecause "a browser is a single client with a single cookie". That advice was wrong and it cost the first integrator a day of debugging. A browser does not collide with its own tabs, but a user's browsers collide with each other: laptop Chrome, a kiosk, a phone browser and a native app each hold a separate refresh cookie and, sending no id, all shared the single no-device_idfamily — so whichever refreshed second was read as a replay and the family was revoked. - The recommendation is now every client sends one, native and web; in a browser, a random id in
localStorage, one per browser profile, because the right scope for an id is the scope of the cookie jar. Omitting it is still accepted and still unchanged behaviour.
New: concurrent refresh is the integrator's responsibility
- Added "One refresh at a time, per family".
device_idseparates clients from each other; it does not protect a client from itself, and nothing in the docs said so. Whatever holds a family must serialise its own refreshes, or the loser of a race looks exactly like theft. - Documents the two mistakes the reference integration actually made, both of which produced revocations that looked like server bugs: a
localStorageread-then-write "lock" is not atomic and grants itself to every tab that starts together (usenavigator.locks.request()), andBroadcastChanneldoes not echo to the tab that posted, so it cannot deduplicate callers within one tab (keep one in-flight promise per tab). - The replay-revocation table and the DO/DO-NOT lists were reworded to stop equating "no
device_id" with "web".
v4.1.1 (2026-09-06)
Fix: device_id was ignored on the SSO fast path
- v4.1.0 bound
device_idonly in the OAuth provider flow. A returning app with a livekm_ssocookie never reaches that flow —GET /loginissues tokens directly and redirects — so in practice the common case still produced unscoped tokens and the re-auth loop continued. Both SSO fast-path issue points now honourdevice_id. - Also threaded through
POST /auth/accept-inviteandPOST /auth/totp/verify(both gained an optionaldevice_idfield), and the OAuth invite-accept path (which reads it from the same state blob ascode_challenge). - Still unscoped, and known:
GET /auth/magic-link/verify— the id would have to be recorded on the magic link when it is requested, which needs a column onmagic_link_tokens— and OAuth account-link confirmation, which is not a login path.
v4.1.0 (2026-09-06)
Device-scoped refresh tokens (fixes a re-auth loop on native apps)
GET /loginandGET /oauth/{provider}/startaccept an optionaldevice_id. A native install that sends a stable per-device id gets its own refresh-token family; the id travels in the OAuth state blob exactly ascode_challengedoes.- Why it matters: refresh-token replay revocation was already device-scoped, but the OAuth path never supplied a
device_id, so every client one user owned shared a single rotating lineage. With rotation every ~15 minutes, the second client to refresh presented a token the first had already rotated away, which reads as a replay and revoked the wholeweb_sessionsscope. The symptom was biometric sign-in appearing to succeed and then landing back on the Keymaster login page. - Not breaking. Omitting
device_idis exactly the previous behaviour; web clients need not send one. Apps opt in, and the scoping applies from their next login — existing tokens keep theirNULLdevice_id. - Known gap, unchanged here:
oauthLogin()on the login page does not forwardcode_challenge, so PKCE presented to/loginis still dropped on the provider-button path.
App Lifecycle: Archive / Restore / Purge (new)
- Apps can now be deleted. Previously there was no supported way to remove an app — no API endpoint and no Console control, only
is_active: false. - Deletion is staged:
DELETE /admin/apps/{id}archives (immediate effect, refresh tokens revoked, restorable untilpurge_after), and a background sweep purges after the retention window (APP_ARCHIVE_RETENTION_DAYS, default 30).POST /admin/apps/{id}/restoreundoes an archive;POST /admin/maintenance/purge-archivedruns the sweep on demand. - A platform admin can force an immediate purge with
{"purge_now": true, "confirm_name": "<exact app name>"}. apps:writeservice tokens may archive/restore within their own tenant (reversible, so safe to automate offboarding) but may not purge.- Purging keeps the audit trail: migration 0016 drops the
audit_logFKs to apps/tenants so history outlives the entity, and adeleted_entitiestombstone resolves a purged id back to a name. - Archived apps are refused everywhere — login, token issuance, service-token auth, and Console app pickers.
- An archived app keeps its
bundle_id.POST/PUT /admin/appsnow return409when abundle_idis already claimed by another app (archived included), naming the conflicting app and pointing at restore — otherwise a returning tenant would get a duplicate app and the archived one would be orphaned.bundle_idhas never been unique in the schema;app_idremains the only guaranteed-unique key. GET /admin/apps?status=active|archived|allreplaces?archived=true. Provisioning should usestatus=allbefore deciding to create.
Console
- API Scopes editor on the app page — grant
client_credentialsscopes without touching the DB.users:createis platform-admin only (it writes to the global cross-tenant user table). - Pagination on Users / Apps / Tenants, and on the app page's push log, enrolled users, and invites.
- The duplicate audit viewer was consolidated into
/console/audit-log(/console/auditnow redirects).
Migrations (auto-applied at startup)
apps.is_console_app(with backfill from the historical bundle_id convention)totp_devices.last_timestepmagic_link_tokens.code_challenge/code_challenge_methodwebhook_endpoints.secret_encself-heal for databases that missed migration 0012
⚠️ Upgrade Notes
- Web integrations must exchange the
code— see the breaking change above. Native custom-scheme apps are unaffected. - Set required secrets or the app will not start on a non-local
GATEWAY_BASE_URL:PLATFORM_ADMIN_PASSWORD,TOTP_ENCRYPTION_KEY,WEBHOOK_SECRET_ENCRYPTION_KEY,INVITE_CODE_HMAC_KEY(andPUSH_TOKEN_ENCRYPTION_KEYwhen push is enabled). - Set
TRUSTED_PROXY_HOPSto match your reverse-proxy chain (default 1). - Grant
config["service_scopes"]to every app usingclient_credentials, or its token requests will return400 invalid_scope. This is not the same key as the OIDCconfig["scopes"]. - Setting
INVITE_CODE_HMAC_KEYinvalidates existing invite codes and TOTP backup codes (they were hashed without it). Re-issue invites and have users regenerate backup codes. Authenticator apps are unaffected.
v4.0.0 (2026-08-19)
Authorization Code Flow for Web (⚠️ Breaking for web apps)
GET /loginnow returns a one-timecodefor web/httpsredirect_uris instead of tokens in the callback URL. Exchange the code at the newPOST /token/exchange({code, app_id[, code_verifier]}) for the token pair. Codes are single-use with a 60-second TTL.- Tokens-in-URL is retained only for native custom-scheme redirect URIs (mobile deep links). Callback delivery is scheme-gated: web/https →
?code=; native →?access_token=&refresh_token=. Auth failures return the user to Keymaster's own login page (/login?error=), not to the app's redirect_uri. - ⚠️ Breaking change: web apps that read
access_token/refresh_tokendirectly from the callback URL will break (redirect loop). They must switch to the code exchange.
PKCE Support
GET /loginacceptscode_challenge+code_challenge_method=S256; pass the matchingcode_verifiertoPOST /token/exchange.
Client Credentials Scope Entitlement
POST /auth/token(client_credentials) now grants only scopes the app is entitled to — each requested scope must be listed in the app'sconfig["service_scopes"], otherwise the request returns400 invalid_scope.
Token Type Enforcement
- User access tokens now carry a
token_type: "access"claim (service tokens usetoken_type: "service"). POST /token/verifynow requires anaudience(pass yourapp_id) and rejects service tokens presented as user tokens.
Security Hardening
Full pre-open-source security audit and remediation (see docs/SECURITY-AUDIT.md, tracked in docs/REMEDIATION.md).
- Account takeover fixed —
/auth/signupand invite redemption no longer issue tokens for an existing account without credentials; an unbound invite can no longer attach to (or set a password on) an existing user. - Audience enforcement — account, TOTP, console, push, and
/token/verifyendpoints now pin the expected audience; service tokens are rejected as user tokens. - Tokens out of redirect URLs for web (see the code flow above).
- Auth codes are no longer replayable from the in-memory fallback after their Redis TTL lapses.
- Atomic refresh rotation — concurrent refreshes can no longer fork a token family and evade reuse detection.
- SSO revocation propagates across workers (logout / password change now take effect everywhere within ~30s).
- SSRF — webhook and push-receipt delivery resolve once and verify the connected peer IP (DNS-rebinding defense).
- Client IP is derived from a trusted-proxy hop count (
TRUSTED_PROXY_HOPS) instead of the rawX-Forwarded-For, restoring IP rate limits. - Security headers — CSP,
X-Frame-Options: DENY,nosniff,Referrer-Policy, HSTS. - Startup validation — refuses to boot on a non-local deployment with a default admin password or missing encryption/HMAC keys.
- Console — stored-XSS sink removed (inline handlers →
data-*);km_console/km_accountare nowHttpOnly; push webhook secret no longer rendered into the page. - OAuth — email auto-linking requires a provider-verified email;
stateis bound to the browser via an HttpOnly nonce cookie; Appleid_tokenpins the algorithm and verifies a nonce. - Authorization — tenant-admin identity now derives from the
is_console_appcolumn rather than a mutablebundle_id; assigned roles are validated against the app'savailable_roles; last-platform-admin protection. - TOTP replay — a code's timestep is recorded and reused codes are rejected.
Tenant-Level App Provisioning (new)
- New machine scopes
apps:readandapps:writelet an app's client credentials act as a tenant-scoped provisioning key: list apps in its tenant, and create/update them viaPOST /admin/apps/PUT /admin/apps/{id}. - Hard constraints: same-tenant only, inactive tenants rejected, and a service token may never set
service_scopes,is_console_app, or a Console-reservedbundle_id(each would be a self-escalation path).apps:readresponses redactservice_scopesand push webhook secrets. - Platform-admin-only to grant. Secret rotation remains Console-only.
- See Tenant-Level App Provisioning.
v3.3.0 (2026-03-22)
Expanded Client Credentials Scopes
invites:create— apps can programmatically create and email invites for their own users viaPOST /admin/invitesusing a service JWTusers:read— apps can list their own enrolled users viaGET /admin/users?app_id=using a service JWTusers:create— apps can create user stubs (email + display_name) viaPOST /admin/usersusing a service JWT (cannot setis_platform_admin)- All new scopes enforce own-app-only access — the service token's
sub(app_id) must match the target resource - Audit log entries from service tokens include
"source": "service_token"in detail - See Server-to-Server Guide for updated client examples (Python + Node.js)
Cleanup
- v2.0 Phase 4 complete —
UserTenantRolemodel, migration shim, and DB table removed. All tenant admin access via per-tenant Console appUserAppRoleexclusively.
v3.1.0 (2026-03-19)
Per-App Push Configuration
- Push config endpoint:
PATCH /push/config/{app_id}— enable/disable push notifications, set webhook URL, configure daily quota per app - Push status endpoint:
GET /push/config/{app_id}— returns enabled state, webhook URL, webhook secret, daily quota, device count, and today's notification count - Device listing endpoint:
GET /push/devices/{app_id}— list all registered devices for an app (admin-only) - Console UI: Notifications tab on App Detail page for managing push config
v3.0.0 (2026-03-19)
Push Notification System
- FCM-based push delivery — Keymaster brokers notifications to Android, iOS, and Web via Firebase Cloud Messaging
- PushDevice model — device registration with platform tracking (web, ios, android), deduplication on
(app_id, token_hash) - Encrypted push tokens — all FCM/APNs tokens encrypted at rest (AES-256-GCM)
- Device registration:
POST /push/devices/register— register a device (user JWT), rate limited to 10/user/hour - Device unregister:
DELETE /push/devices/{device_id}— user or app-initiated removal - Send single:
POST /push/send— send to one user's devices (service JWT,push:sendscope) - Send bulk:
POST /push/send/bulk— same message to up to 500 users - Send batch:
POST /push/send/batch— different messages in one call (up to 500) - Delivery receipts — webhook callbacks with HMAC-SHA256 signature verification
- Auto-cleanup — invalid tokens automatically removed on FCM
invalid_tokenresponse - Daily quota enforcement — 10,000 notifications/day per app (default), 429 with
Retry-Afteron exceeded
v2.4.0 (2026-03-19)
Passwordless Accounts
- Password is now optional on all registration flows: invite redeem, accept-invite, and open signup
- Users can create accounts via OAuth only and set a password later from the Account page
- New endpoint:
POST /account/set-password— allows OAuth-only users to add a password to their account
Export/Import
- App export:
GET /admin/apps/{id}/export— export app configuration as JSON - Optional
?include_users=truequery param to include user enrollments - Tenant export:
GET /admin/tenants/{id}/export— export tenant and all its apps - App import:
POST /admin/apps/import— import an app into a tenant - Body:
{ tenant_id, app, preserve_bundle_id } - Tenant import:
POST /admin/tenants/import— import a tenant and all its apps - Body:
{ tenant, apps } - Import always generates new IDs and client secrets. Users are optionally imported.
Fixes
- User delete cascade — deleting a user now properly cascades all related records (SSO sessions, TOTP secrets, backup codes, magic links, email tokens)
v2.1.0 (2026-03-17)
Console — Tiered Impersonation
- Tenant admin → app admin impersonation — tenant admins can enter any app in their tenant as app admin (Console-only, audited, session-scoped)
- Platform admin → tenant admin impersonation (introduced in v2.0.0) now stacks with app impersonation
- Amber banner with exit link for both impersonation tiers
- Audit events:
app_impersonation_started,app_impersonation_ended
Console — SSO-Based Authentication
- Console login now uses SSO session (
km_sso) directly — no app enrollment required to access the Console - Console JWT issued with audience
keymaster-console(decoupled from any specific app) - Platform Keymaster Console app serves as auth gateway only (open policy)
- Per-tenant Console apps serve as role containers (tenant admin =
adminrole on tenant Console app)
Console — Multi-Tenant UX
- Tenant picker for users who are admin on multiple tenants
- "Switch Tenant" sidebar link only shown when user has 2+ tenants
- Active tenant stored in session cookie (
km_active_tenant)
Fixes
get_user_tenant_idsnow impersonation-aware — all routes work correctly during impersonation- Platform admins auto-enrolled in Console apps on login regardless of registration policy
v2.0.0 (2026-03-17)
Per-Tenant Console Isolation
- Each tenant now gets its own Console app (
{Tenant Name} Console), auto-created on tenant bootstrap - Tenant admin access is determined by
adminrole on the per-tenant Console app (replacesUserTenantRole) - Platform admins access tenant contexts via impersonation, not enrollment
- Tenant creation now requires an admin email — no orphan tenants
Platform Admin Impersonation
- Platform admins can impersonate any tenant admin (Console-only, no SSO tokens issued)
- Session-scoped via
km_impersonatecookie (1-hour TTL) - All impersonated actions logged with
impersonated_byin audit trail - Visible to tenant admins in their audit log
SDK Developer Docs Portal
- Full SDK documentation at
/docs/(also accessible from Console sidebar) - LLM-digestible endpoints:
/llms.txt,/llms-full.txt - OpenAPI/Swagger moved to
/api/docs build-docs.shscript for regenerating docs on feature changes- Version display in Console sidebar footer
Breaking Changes
UserTenantRoletable deprecated — tenant admin access now via per-tenant Console app enrollment- Console JWT audience changed from app ID to
keymaster-console
v1.0.1 (2026-03-17)
Fixes
- Docs routing: handle
.mdextensions in URLs - Swagger/OpenAPI moved to
/api/docs(was/docs) - Landing page updated with SDK docs link
v1.0.0 (2026-03-17)
Initial public release.
Authentication
- Password + Google + GitHub + Microsoft + Apple OAuth (shared provider registration)
- Cross-app SSO via
km_ssocookie (8-hour sessions) - Magic links (passwordless email login, 15-min expiry)
- TOTP 2FA with encrypted secrets + backup codes
- OAuth invite-required flow (policy-aware routing for invite-only apps)
Tokens
- RS256 JWT access tokens (configurable TTL, default 15 min)
- Refresh token rotation with replay detection (30-day rolling window)
- Client credentials grant (
POST /auth/token,grant_type=client_credentials) - Service tokens with scope enforcement (
push:send)
User Management
- Three-tier admin: platform admin → tenant admin → app admin
- App roles:
user,manager,admin(standardized) - Manager role: invite/revoke users only, no config access
- Invite system with email delivery, per-app branding, expiration
- Password policy: 16-char minimum, configurable per tenant
Console
- Full admin Console with dark/light theme
- App configuration: branding, auth methods, token TTLs, redirect URIs
- User management: invite, roles, suspend, rate limit viewer
- Tenant admin dashboard with admin management
- Audit log viewer with filtering and pagination
- Session management (view/revoke active SSO sessions)
- In-app help system (role-scoped, searchable)
Infrastructure
- App logo upload (base64 encoded, served via public endpoint for emails)
- Outbound webhooks (HMAC-SHA256 signed, retry with backoff, SSRF protection)
- Redis session store (OAuth state, SSO cache, rate limiting)
- Health check endpoint (
GET /health) - Email infrastructure (Postfix DKIM, branded templates)
- Auto-apply migrations on startup
- OIDC discovery + JWKS endpoints
Security
- Argon2id password hashing
- TOTP secret encryption (AES-256-GCM)
- Push token encryption at rest
- Webhook URL SSRF validation (blocks private/reserved IP ranges)
- Rate limiting (Redis + in-memory fallback, auto-clear on success)
- Refresh token replay detection with automatic revocation