Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-usernames-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@devalexllc/interlock-auth": minor
---

Carry Keycloak’s preferred_username as user.username and refresh mapped fields on login.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/core/__tests__/auth.options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 });
Expand Down
15 changes: 11 additions & 4 deletions src/core/__tests__/cookie-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/core/__tests__/exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
14 changes: 13 additions & 1 deletion src/core/__tests__/keycloak-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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 });

Expand Down
8 changes: 7 additions & 1 deletion src/core/auth.options.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions src/core/keycloak-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/core/keycloak-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { BetterAuthOptions } from "better-auth";

import { mapKeycloakProfile } from "./keycloak-profile.js";

type UserOptions = NonNullable<BetterAuthOptions["user"]>;
type UserHooks = NonNullable<NonNullable<BetterAuthOptions["databaseHooks"]>["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<object, string | null>();
const validateUserInfo: NonNullable<UserOptions["validateUserInfo"]> = ({ source }, ctx) => {
if (source.method === "oauth" && source.oauth?.providerId === "keycloak") {
usernames.set(ctx.context, mapKeycloakProfile(source.oauth.profile ?? {}).username ?? null);
}
};
const before: NonNullable<NonNullable<UserHooks["update"]>["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,
};
}
Loading