From ce2806a11ec6558318656aa92c893c69538b4bee Mon Sep 17 00:00:00 2001 From: KennHuang Date: Wed, 9 Sep 2026 13:49:45 +0800 Subject: [PATCH 1/4] fix: check client role in addition to realm role for admin getRolesFromAccessToken only read realm_access.roles from the Keycloak access token, but the admin role is assigned as a client role (resource_access..roles), so it never matched and every user was demoted to "user" in production. Now roles are merged from both realm_access.roles and resource_access[AUTH_KEYCLOAK_ID].roles before checking for "admin". Co-Authored-By: Claude Sonnet 5 --- src/auth.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index 3a96ebf..026ccf5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -33,7 +33,14 @@ declare module "next-auth/jwt" { function getRolesFromAccessToken(accessToken: string): string[] { const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()); - return Array.isArray(payload?.realm_access?.roles) ? payload.realm_access.roles : []; + const realmRoles = Array.isArray(payload?.realm_access?.roles) ? payload.realm_access.roles : []; + + const clientId = process.env.AUTH_KEYCLOAK_ID; + const clientRoles = clientId && Array.isArray(payload?.resource_access?.[clientId]?.roles) + ? payload.resource_access[clientId].roles + : []; + + return [...realmRoles, ...clientRoles]; } function getNameFromProfile(profile: Profile): string | undefined { From a48f0e188a8c70f9133067828aa4102ff7e27adf Mon Sep 17 00:00:00 2001 From: KennHuang Date: Wed, 9 Sep 2026 14:00:06 +0800 Subject: [PATCH 2/4] fix: only check client role, drop realm_access lookup admin is exclusively a client role, so checking realm_access.roles was unnecessary and could theoretically false-positive on an unrelated realm role of the same name. Co-Authored-By: Claude Sonnet 5 --- src/auth.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 026ccf5..91bc066 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -33,14 +33,11 @@ declare module "next-auth/jwt" { function getRolesFromAccessToken(accessToken: string): string[] { const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()); - const realmRoles = Array.isArray(payload?.realm_access?.roles) ? payload.realm_access.roles : []; - const clientId = process.env.AUTH_KEYCLOAK_ID; - const clientRoles = clientId && Array.isArray(payload?.resource_access?.[clientId]?.roles) + + return clientId && Array.isArray(payload?.resource_access?.[clientId]?.roles) ? payload.resource_access[clientId].roles : []; - - return [...realmRoles, ...clientRoles]; } function getNameFromProfile(profile: Profile): string | undefined { From a8489f6b9f2e8847dfe8e212cf307222c9bf6626 Mon Sep 17 00:00:00 2001 From: KennHuang Date: Wed, 9 Sep 2026 14:08:07 +0800 Subject: [PATCH 3/4] fix: revert role source to profile.roles, restore strict refresh logout, add federated logout support Aligns auth.ts with the sibling project's approach (role types kept binary: admin/user only): - Role is read from profile.roles at sign-in again, instead of being recomputed from the access token's client roles on every jwt() call. Admin revocation now takes effect on next login rather than near-instantly, which is an accepted trade-off here. - Any refresh token failure now forces logout (no longer special-cased for invalid_grant vs transient errors). - Added a redirect callback that allows passthrough to Keycloak's end_session_endpoint, and store id_token on the JWT so a federated Keycloak logout can be wired up later. Co-Authored-By: Claude Sonnet 5 --- src/auth.ts | 70 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 91bc066..c1d9a47 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -24,22 +24,13 @@ declare module "next-auth/jwt" { userId: string; role: string; + idToken?: string; accessToken: string; accessTokenExpiresAt: number; refreshToken?: string; } } -function getRolesFromAccessToken(accessToken: string): string[] { - const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()); - - const clientId = process.env.AUTH_KEYCLOAK_ID; - - return clientId && Array.isArray(payload?.resource_access?.[clientId]?.roles) - ? payload.resource_access[clientId].roles - : []; -} - function getNameFromProfile(profile: Profile): string | undefined { const familyName = profile?.family_name; const givenName = profile?.given_name; @@ -119,36 +110,42 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }); token.userId = dbUser.id; + if (profile && Array.isArray(profile.roles)) { + const roles = profile.roles; + + if (roles.includes('admin')) { + token.role = "admin"; + } + } + + token.idToken = account.id_token; token.accessToken = account.access_token; token.accessTokenExpiresAt = account.expires_at; token.refreshToken = account.refresh_token; - } - if (token.accessTokenExpiresAt && Date.now() / 1000 > token.accessTokenExpiresAt) { - // Access token has expired + return token; + } - if (!token.refreshToken) { - return null; - } + if (token.accessTokenExpiresAt && Date.now() < token.accessTokenExpiresAt * 1000) { + return token; + } - // Refresh the access token - try { - const tokensOrError = await refreshAccessToken(token.refreshToken); + if (!token.refreshToken) { + return null; + } - token.accessToken = tokensOrError.access_token; - token.accessTokenExpiresAt = Math.floor(Date.now() / 1000 + tokensOrError.expires_in); - token.refreshToken = tokensOrError.refresh_token || token.refreshToken; - } catch (error) { - if (typeof error === 'object' && error !== null && 'error' in error && error.error === 'invalid_grant') { - return null; - } + // Refresh the access token + try { + const tokensOrError = await refreshAccessToken(token.refreshToken); - console.error("Error refreshing access_token", error); - } + token.accessToken = tokensOrError.access_token; + token.accessTokenExpiresAt = Math.floor(Date.now() / 1000 + tokensOrError.expires_in); + token.refreshToken = tokensOrError.refresh_token || token.refreshToken; + if (tokensOrError.id_token) token.idToken = tokensOrError.id_token; + } catch { + return null; } - token.role = getRolesFromAccessToken(token.accessToken).includes("admin") ? "admin" : "user"; - return token; }, session({ session, token }) { @@ -163,5 +160,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } return session; }, + redirect({ url, baseUrl }) { + const issuer = process.env.AUTH_KEYCLOAK_ISSUER; + if (issuer && url.startsWith(`${issuer}/protocol/openid-connect/logout`)) { + return url; + } + if (url.startsWith('/')) return `${baseUrl}${url}`; + try { + if (new URL(url).origin === baseUrl) return url; + } catch { + // ignore invalid URL, fall through to baseUrl + } + return baseUrl; + }, }, }); From 1f834d9bfc6e873eb13bcb7922485313b6a7b354 Mon Sep 17 00:00:00 2001 From: KennHuang Date: Wed, 9 Sep 2026 14:16:35 +0800 Subject: [PATCH 4/4] fix: revert to access-token role source, make roles claim path configurable Reverts a8489f6's move back to profile.roles (which reintroduces the "admin revoked but session stays admin until relogin" gap) in favor of the access-token approach, but generalizes it: instead of hardcoding resource_access[AUTH_KEYCLOAK_ID].roles, the claim path is now read from AUTH_KEYCLOAK_ROLES_CLAIM (dot-separated, e.g. "resource_access..roles" or "realm_access.roles"), defaulting to "realm_access.roles" if unset. This makes the realm-role-vs-client-role choice a deployment config instead of a code change, so switching it in Keycloak doesn't require another PR. Co-Authored-By: Claude Sonnet 5 --- src/auth.ts | 80 ++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index c1d9a47..6a30163 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -24,13 +24,32 @@ declare module "next-auth/jwt" { userId: string; role: string; - idToken?: string; accessToken: string; accessTokenExpiresAt: number; refreshToken?: string; } } +// Dot-separated path into the access token payload where the roles array lives, e.g. +// "realm_access.roles" for a realm role, or "resource_access..roles" for a client role. +const ROLES_CLAIM_PATH = process.env.AUTH_KEYCLOAK_ROLES_CLAIM || "realm_access.roles"; + +function getClaimByPath(payload: unknown, path: string): unknown { + return path.split(".").reduce((value, key) => { + if (value && typeof value === "object" && key in value) { + return (value as Record)[key]; + } + return undefined; + }, payload); +} + +function getRolesFromAccessToken(accessToken: string): string[] { + const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()); + + const roles = getClaimByPath(payload, ROLES_CLAIM_PATH); + return Array.isArray(roles) ? roles : []; +} + function getNameFromProfile(profile: Profile): string | undefined { const familyName = profile?.family_name; const givenName = profile?.given_name; @@ -110,42 +129,36 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ }); token.userId = dbUser.id; - if (profile && Array.isArray(profile.roles)) { - const roles = profile.roles; - - if (roles.includes('admin')) { - token.role = "admin"; - } - } - - token.idToken = account.id_token; token.accessToken = account.access_token; token.accessTokenExpiresAt = account.expires_at; token.refreshToken = account.refresh_token; - - return token; } - if (token.accessTokenExpiresAt && Date.now() < token.accessTokenExpiresAt * 1000) { - return token; - } + if (token.accessTokenExpiresAt && Date.now() / 1000 > token.accessTokenExpiresAt) { + // Access token has expired - if (!token.refreshToken) { - return null; - } + if (!token.refreshToken) { + return null; + } + + // Refresh the access token + try { + const tokensOrError = await refreshAccessToken(token.refreshToken); - // Refresh the access token - try { - const tokensOrError = await refreshAccessToken(token.refreshToken); + token.accessToken = tokensOrError.access_token; + token.accessTokenExpiresAt = Math.floor(Date.now() / 1000 + tokensOrError.expires_in); + token.refreshToken = tokensOrError.refresh_token || token.refreshToken; + } catch (error) { + if (typeof error === 'object' && error !== null && 'error' in error && error.error === 'invalid_grant') { + return null; + } - token.accessToken = tokensOrError.access_token; - token.accessTokenExpiresAt = Math.floor(Date.now() / 1000 + tokensOrError.expires_in); - token.refreshToken = tokensOrError.refresh_token || token.refreshToken; - if (tokensOrError.id_token) token.idToken = tokensOrError.id_token; - } catch { - return null; + console.error("Error refreshing access_token", error); + } } + token.role = getRolesFromAccessToken(token.accessToken).includes("admin") ? "admin" : "user"; + return token; }, session({ session, token }) { @@ -160,18 +173,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } return session; }, - redirect({ url, baseUrl }) { - const issuer = process.env.AUTH_KEYCLOAK_ISSUER; - if (issuer && url.startsWith(`${issuer}/protocol/openid-connect/logout`)) { - return url; - } - if (url.startsWith('/')) return `${baseUrl}${url}`; - try { - if (new URL(url).origin === baseUrl) return url; - } catch { - // ignore invalid URL, fall through to baseUrl - } - return baseUrl; - }, }, });