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..9a57eda 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,15 @@ 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. `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 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, + }; +}