166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
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<string, Record<string, string>>
|
|
): 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);
|