import NextAuth from "next-auth"; import type { NextAuthConfig } from "next-auth"; import type { PlatformRole, Role, SessionUser, ZitadelClaims } from "@/types"; import { isPersonalOrgName } from "@/lib/personal-org"; const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"]; /** * Pull the role keys from the ZITADEL `urn:zitadel:iam:org:project:roles` * claim. The claim is shaped as { roleKey: { orgId: orgName } } — we only * need the keys. * * Slice 5: returns Role[] (the union) rather than PlatformRole[]. The * keys can be either platform or customer roles depending on what the * project authorization granted; the SessionUser carries them all and * downstream helpers (canMutate, isCustomerOwner, requirePlatformRole) * decide what each subset means. */ function extractRoles( rolesObj?: Record> ): Role[] { if (!rolesObj) return []; return Object.keys(rolesObj) as Role[]; } export const authConfig: NextAuthConfig = { providers: [ { id: "zitadel", name: "ZITADEL", type: "oidc", issuer: process.env.ZITADEL_ISSUER!, clientId: process.env.ZITADEL_CLIENT_ID!, clientSecret: process.env.ZITADEL_CLIENT_SECRET!, idToken: false, authorization: { params: { scope: "openid profile email urn:zitadel:iam:org:project:roles urn:zitadel:iam:user:resourceowner", }, }, profile(profile) { return { id: profile.sub, name: profile.name, email: profile.email, }; }, }, ], callbacks: { async jwt({ token, account, profile, trigger, session }) { // Phase 6 fix5: client-side `useSession().update({ name })` calls // route through this branch. We trust the new value because the // PUT /api/settings/profile route already wrote it to ZITADEL // and re-fetched the canonical displayName before returning. // The session callback reads token.name directly (see below) so // the update propagates without depending on auth.js's implicit // token→session.user mapping, which is flaky for the name claim // in the v5 OIDC provider configuration. // // Defensive: only the `name` field is accepted from the update // payload, even if the client passes additional keys. Other // identity claims (orgId, roles, sub) come from ZITADEL at // sign-in time and are not user-mutable from a settings page. // // Returns a NEW token object (spread) rather than mutating, so // there is no ambiguity for auth.js about whether the token // changed and needs re-encoding into the session cookie. if (trigger === "update" && session) { const update = session as { name?: unknown }; if (typeof update.name === "string") { return { ...token, name: update.name }; } return token; } if (account && profile) { const claims = profile as unknown as ZitadelClaims; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgName = claims["urn:zitadel:iam:user:resourceowner:name"]; token.roles = extractRoles( claims["urn:zitadel:iam:org:project:roles"] ); token.accessToken = account.access_token; // Phase 6 fix5: explicitly pin the standard name/email claims // onto the token from the OIDC profile. Previously these came // through auth.js's implicit mapping, which works on first // sign-in but isn't reliable after update() — once the update // path overrides token.name, the read-back path needs token // to be the authoritative source. Setting them explicitly // here keeps sign-in and update on the same path. if (typeof profile.name === "string") { token.name = profile.name; } if (typeof profile.email === "string") { token.email = profile.email; } // Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a // freshly generated UUID in token.sub on initial sign-in, // ignoring what profile() returns for `id`. That UUID then // becomes session.user.id everywhere downstream — including // `tenant_user_assignments.assigned_by` and (more importantly) // the WHERE clause used to look up the invited user's // assignments on the dashboard. With a UUID in the session and // a ZITADEL snowflake in the DB, the lookup matches nothing // and assigned tenants never appear (Bug 27). // // Reference: https://github.com/nextauthjs/next-auth/issues/11174 // Auth.js respects an explicit token.sub assignment; the // override below is preserved across subsequent jwt() calls. if (typeof profile.sub === "string") { token.sub = profile.sub; } } return token; }, async session({ session, token }) { const roles = (token.roles as Role[]) ?? []; const orgName = (token.orgName as string) ?? ""; // Phase 6 fix5: read name and email directly from the token. // Previously this code relied on `session.user?.name`, expecting // auth.js to map token.name → session.user.name automatically. // That mapping is brittle: it works on first sign-in (because // OIDC profile() populates session.user) but not after update() // overrides token.name. Reading from token is the canonical // path regardless of how the token was last written. const tokenName = (token.name as string | undefined) ?? ""; const tokenEmail = (token.email as string | undefined) ?? ""; const sessionUser: SessionUser = { id: token.sub!, name: tokenName || session.user?.name || "", email: tokenEmail || session.user?.email || "", orgId: token.orgId as string, orgName, roles, isPlatform: roles.some((r) => PLATFORM_ROLES.includes(r as PlatformRole) ), // Derived from orgName — see lib/personal-org.ts. Recognises // both legacy " (Personal)" suffix and current "personal-{8hex}" // opaque names. isPersonal: isPersonalOrgName(orgName), }; (session as any).platformUser = sessionUser; // Also overwrite session.user so any client-side code that uses // the standard NextAuth shape (session.user.name) sees the new // value. Pre-fix5 code paths read from session.user.name; this // keeps them working without per-component changes. if (session.user) { session.user.name = sessionUser.name; session.user.email = sessionUser.email; } return session; }, }, pages: { signIn: "/login", }, session: { strategy: "jwt", maxAge: 8 * 60 * 60, }, }; export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);