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 }) { 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; // 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) ?? ""; const sessionUser: SessionUser = { id: token.sub!, name: session.user?.name ?? "", email: 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; return session; }, }, pages: { signIn: "/login", }, session: { strategy: "jwt", maxAge: 8 * 60 * 60, }, }; export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);