From 33f922ad5bc6f284e8e18308bd9a78f542978e19 Mon Sep 17 00:00:00 2001 From: Brandon Mitchell Date: Wed, 21 Oct 2026 15:27:03 +0000 Subject: [PATCH 1/2] feat: carry preferred_username as the user's username Keep username server-owned while carrying verified Keycloak profile fields through Better Auth's validation and database hooks. Existing users receive the field on their next login after the host runs the Better Auth migrator. --- .changeset/quiet-usernames-grow.md | 5 ++++ README.md | 6 ++++ src/core/__tests__/auth.options.test.ts | 4 +++ src/core/__tests__/cookie-policy.test.ts | 15 +++++++--- src/core/__tests__/exposure.test.ts | 16 ++++++++++ src/core/__tests__/keycloak-profile.test.ts | 14 ++++++++- src/core/auth.options.ts | 8 ++++- src/core/keycloak-profile.ts | 1 + src/core/keycloak-user.ts | 33 +++++++++++++++++++++ 9 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 .changeset/quiet-usernames-grow.md create mode 100644 src/core/keycloak-user.ts diff --git a/.changeset/quiet-usernames-grow.md b/.changeset/quiet-usernames-grow.md new file mode 100644 index 0000000..80d8d40 --- /dev/null +++ b/.changeset/quiet-usernames-grow.md @@ -0,0 +1,5 @@ +--- +"@devalexllc/interlock-auth": minor +--- + +Carry Keycloak’s preferred_username as user.username and refresh mapped fields on login. diff --git a/README.md b/README.md index b8b5e2c..2906d82 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ entry exports: back-channel logout receiver, and the cookie-name policy. It is pure: nothing connects until Better Auth initializes. `createPostgresStore`, `cookieSettings` and `KEYCLOAK_SCOPES` are the pieces it uses. +- **Username.** `user.username` carries Keycloak’s `preferred_username` independently of + the display name. The optional, server-managed field is added by Better Auth’s migrator. + `overrideUserInfo` refreshes mapped fields on login, so existing users acquire their + username on their next login; no backfill is needed. The field stays `input: false`: a + request-scoped provider-validation hook carries the verified username into user writes, + because Better Auth 1.7.7 otherwise strips it from OAuth profiles too. - **Session-scoped grants.** Each session's Keycloak tokens live on its own `session_grant` row, never on `account`. `KyselyGrantStore` is the runtime store; `GrantStore` is its interface. `createGrantRefresher` builds the refresh lease state machine, whose diff --git a/src/core/__tests__/auth.options.test.ts b/src/core/__tests__/auth.options.test.ts index c34e3bb..d5d31ea 100644 --- a/src/core/__tests__/auth.options.test.ts +++ b/src/core/__tests__/auth.options.test.ts @@ -71,6 +71,7 @@ describe("createKeycloakAuthOptions", () => { tokenUrl: `${base}/protocol/openid-connect/token`, userInfoUrl: `${base}/protocol/openid-connect/userinfo`, disableProviderLogout: true, + overrideUserInfo: true, }); expect(provider.scopes).toEqual(["openid", "profile", "email"]); expect(provider.redirectURI).toBeUndefined(); @@ -82,6 +83,9 @@ describe("createKeycloakAuthOptions", () => { it("should keep sessions and tokens on the server, in the store it is given", () => { const options = build(); + expect(options.user?.additionalFields).toEqual({ + username: { type: "string", required: false, input: false }, + }); expect(options.database).toBe(store); expect(options.session?.cookieCache?.enabled).toBe(false); expect(options.account).toEqual({ encryptOAuthTokens: true, storeAccountCookie: false }); diff --git a/src/core/__tests__/cookie-policy.test.ts b/src/core/__tests__/cookie-policy.test.ts index 044a39d..2dcf2ad 100644 --- a/src/core/__tests__/cookie-policy.test.ts +++ b/src/core/__tests__/cookie-policy.test.ts @@ -50,10 +50,17 @@ describe.each(COOKIE_NAME_POLICIES)("cookie-name policy %s", (policy: CookieName /** A user with a session; `due` makes the session ready for its refresh. */ const signedIn = async (due: boolean) => { const ctx = await auth.$context; - const user = await ctx.internalAdapter.createUser( - { email: `ann-${crypto.randomUUID()}@example.com`, name: "ann", emailVerified: true }, - { method: "oauth", oauth: { providerId: "keycloak" } }, - ); + // Seed the fixture directly: OAuth user writes now require an endpoint context. + const user = await ctx.adapter.create<{ id: string }>({ + model: "user", + data: { + email: `ann-${crypto.randomUUID()}@example.com`, + name: "ann", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); const { expiresIn, updateAge } = ctx.sessionConfig; // get-session refreshes once `expiresAt - expiresIn + updateAge <= now`. // `createSession` spreads the override before its own `expiresAt`, so diff --git a/src/core/__tests__/exposure.test.ts b/src/core/__tests__/exposure.test.ts index 10c33b3..4f6be38 100644 --- a/src/core/__tests__/exposure.test.ts +++ b/src/core/__tests__/exposure.test.ts @@ -120,12 +120,28 @@ describe("route exposure", () => { expect(res.status).toBe(200); expect(body.session.token).toEqual(expect.any(String)); + expect(body).toMatchObject({ user: { username: "ann" } }); const keys = keysOf(body); for (const field of TOKEN_FIELDS) { expect(keys).not.toContain(field); } }); + it("should populate an existing user’s username on the next login", async () => { + const setup = buildAuth(); + await setup.login(); + const user = setup.tables.user?.[0]; + expect(user).toBeDefined(); + if (!user) throw new Error("Expected a user"); + delete user.username; + const { cookie } = await setup.login(); + const res = await setup.auth.handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie } }), + ); + expect(await res.json()).toMatchObject({ user: { username: "ann" } }); + expect(setup.tables.user).toHaveLength(1); + }); + describe("an idToken sign-in body, with discovery on", () => { it("should be a 400 before any Keycloak or JWKS contact", async () => { const setup = buildAuth({ discovery: true }); diff --git a/src/core/__tests__/keycloak-profile.test.ts b/src/core/__tests__/keycloak-profile.test.ts index d4b4dff..2168415 100644 --- a/src/core/__tests__/keycloak-profile.test.ts +++ b/src/core/__tests__/keycloak-profile.test.ts @@ -16,7 +16,12 @@ describe("mapKeycloakProfile", () => { email: "ann@example.com", email_verified: true, }), - ).toEqual({ name: "Ann Example", email: "ann@example.com", emailVerified: true }); + ).toEqual({ + username: "ann", + name: "Ann Example", + email: "ann@example.com", + emailVerified: true, + }); }); it.each([ @@ -28,6 +33,13 @@ describe("mapKeycloakProfile", () => { expect(mapKeycloakProfile({ sub: "f3a1", name, preferred_username: "ann" }).name).toBe("ann"); }); + it.each([undefined, null, "", " ", 42])( + "should omit an unusable username (%s)", + (preferred_username) => { + expect(mapKeycloakProfile({ preferred_username }).username).toBeUndefined(); + }, + ); + it("should give a profile without email a placeholder that is never verified", () => { const user = mapKeycloakProfile({ sub: "f3a1", email: " ", email_verified: true }); diff --git a/src/core/auth.options.ts b/src/core/auth.options.ts index 135fb38..d44fe1a 100644 --- a/src/core/auth.options.ts +++ b/src/core/auth.options.ts @@ -1,3 +1,5 @@ +/** Keycloak owns user.username; Better Auth migrates the optional column and refreshes + * mapped user fields on each login, including users created before this field existed. */ import type { BetterAuthOptions } from "better-auth"; import { genericOAuth, keycloak } from "better-auth/plugins/generic-oauth"; import type { Kysely } from "kysely"; @@ -13,6 +15,7 @@ import { import { grantHooks } from "./grants/grant-hooks.js"; import type { GrantStore } from "./grants/grant-store.js"; import { keycloakAccountSubject, mapKeycloakProfile } from "./keycloak-profile.js"; +import { keycloakUserFields } from "./keycloak-user.js"; import type { AuthLogger } from "./logger.js"; import { backchannelLogout } from "./logout/backchannel.js"; import { captureSignOut, finishSignOut } from "./logout/sign-out.js"; @@ -147,6 +150,7 @@ export function createKeycloakAuthOptions( const issuer = config.keycloak.issuer.replace(/\/+$/, ""); const endpoint = (name: string) => `${issuer}/protocol/openid-connect/${name}`; const receiverRoute = receiverRouteOf(config); + const userFields = keycloakUserFields(); return { baseURL: config.betterAuth.url, @@ -169,6 +173,7 @@ export function createKeycloakAuthOptions( // could only write the clamped value back and reissue the cookie on every // `get-session`. The session update hook still clamps any slide. session: { cookieCache: { enabled: false }, disableSessionRefresh: true }, + user: userFields.user, account: { encryptOAuthTokens: true, storeAccountCookie: false }, // The exposure hook runs first, so a route outside the allow-list, or an // `idToken` sign-in, runs nothing else (`exposure.ts`). The callback's @@ -202,7 +207,7 @@ export function createKeycloakAuthOptions( // logouts must never meet a 429; the receiver bounds its own cost (one // JWKS fetch per second at most, `logout/backchannel.ts`). rateLimit: { customRules: { [receiverRoute]: false } }, - databaseHooks: grantHooks(grants, logger), + databaseHooks: { ...grantHooks(grants, logger), user: userFields.hooks }, advanced: { ...cookieSettings(config.cookieNamePolicy, cookiePrefix), // Better Auth turns both checks off whenever NODE_ENV is `test` or TEST @@ -232,6 +237,7 @@ export function createKeycloakAuthOptions( // the server instead (`logout/sign-out.ts`), so no token or // Keycloak URL reaches the browser. disableProviderLogout: true, + overrideUserInfo: true, accountSubject: keycloakAccountSubject, mapProfileToUser: mapKeycloakProfile, }, diff --git a/src/core/keycloak-profile.ts b/src/core/keycloak-profile.ts index a4f8e34..8b774a2 100644 --- a/src/core/keycloak-profile.ts +++ b/src/core/keycloak-profile.ts @@ -38,6 +38,7 @@ const subject = (profile: KeycloakProfile) => { export function mapKeycloakProfile(profile: KeycloakProfile) { const email = claim(profile.email); return { + username: claim(profile.preferred_username), name: claim(profile.name) ?? claim(profile.preferred_username), email: email ?? `${subject(profile) ?? "unknown"}@${PLACEHOLDER_EMAIL_DOMAIN}`, // Set every time: Better Auth spreads this over its own mapping, and a diff --git a/src/core/keycloak-user.ts b/src/core/keycloak-user.ts new file mode 100644 index 0000000..528194b --- /dev/null +++ b/src/core/keycloak-user.ts @@ -0,0 +1,33 @@ +import type { BetterAuthOptions } from "better-auth"; + +import { mapKeycloakProfile } from "./keycloak-profile.js"; + +type UserOptions = NonNullable; +type UserHooks = NonNullable["user"]>; + +/** + * Better Auth 1.7.7 strips input:false fields even from mapped OAuth profiles. + * Carry the verified provider profile across its validation/write seam instead. + * The weak key is the request context, never a user or a process-wide last profile. + */ +export function keycloakUserFields() { + const usernames = new WeakMap(); + const validateUserInfo: NonNullable = ({ source }, ctx) => { + if (source.method === "oauth" && source.oauth?.providerId === "keycloak") { + usernames.set(ctx.context, mapKeycloakProfile(source.oauth.profile ?? {}).username ?? null); + } + }; + const before: NonNullable["before"]> = async (user, ctx) => { + if (ctx && usernames.has(ctx.context)) { + return { data: { ...user, username: usernames.get(ctx.context) } }; + } + return undefined; + }; + return { + user: { + additionalFields: { username: { type: "string", required: false, input: false } }, + validateUserInfo, + } satisfies UserOptions, + hooks: { create: { before }, update: { before } } satisfies UserHooks, + }; +} From 1e2a42fc63f422eaaa43daa165fe4af66198cee2 Mon Sep 17 00:00:00 2001 From: Brandon Mitchell Date: Wed, 21 Oct 2026 16:19:18 +0000 Subject: [PATCH 2/2] docs: say why username stays input: false rather than input: true --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2906d82..9a57eda 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ entry exports: `overrideUserInfo` refreshes mapped fields on login, so existing users acquire their username on their next login; no backfill is needed. The field stays `input: false`: a request-scoped provider-validation hook carries the verified username into user writes, - because Better Auth 1.7.7 otherwise strips it from OAuth profiles too. + because Better Auth 1.7.7 otherwise strips it from OAuth profiles too. `input: true` would + avoid the hook, and Interlock's exposure allow-list already refuses every client route that + writes a user, but a host that mounts Better Auth's full handler would then accept a + username from the browser; the hook keeps the field server-owned under every host. - **Session-scoped grants.** Each session's Keycloak tokens live on its own `session_grant` row, never on `account`. `KyselyGrantStore` is the runtime store; `GrantStore` is its interface. `createGrantRefresher` builds the refresh lease state machine, whose