Files
shared-memory/apps/web/auth.ts
T

100 lines
3.6 KiB
TypeScript
Raw Normal View History

import NextAuth from "next-auth";
import { env } from "@/lib/env";
import { oidClaim, resolveUserId } from "@/lib/auth/identity";
import { syncUserGroupsFromClaim } from "@/lib/auth/sync-groups";
/**
* NextAuth (Auth.js v5) configuration.
*
* Uses a generic OIDC provider so any compliant identity provider works —
* Authentik (the example we run in dev), EntraID, Keycloak, Okta, Auth0,
* Zitadel, etc. The provider id is "oidc", which makes the callback URL
* `/api/auth/callback/oidc`. Whichever IdP you're using needs that URL
* registered as a redirect URI on its OAuth client.
*
* We store the user's OIDC `sub` + `iss` on first sign-in, upserting a row
* in `users`. The internal user UUID lives on the JWT/session so
* downstream code never has to re-resolve it.
*/
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [
{
id: "oidc",
name: "OIDC",
type: "oidc",
issuer: env().OIDC_ISSUER,
clientId: env().OIDC_CLIENT_ID_WEB,
clientSecret: env().OIDC_CLIENT_SECRET_WEB,
},
],
secret: env().NEXTAUTH_SECRET,
session: { strategy: "jwt" },
// No custom `pages.signIn`: Auth.js serves its default provider-picker UI
// at /api/auth/signin. Setting it to that exact path causes a redirect
// loop because Auth.js redirects to the configured page → which is itself.
callbacks: {
async jwt({ token, account, profile }) {
// On first call after sign-in, `account` + `profile` are populated.
if (account && profile) {
const sub = profile.sub;
const iss = (profile.iss as string | undefined) ?? env().OIDC_ISSUER;
if (!sub) throw new Error("OIDC profile missing `sub` claim");
// Shared with the MCP path (lib/mcp/context.ts). On EntraID the `oid`
// claim is what keeps the two surfaces resolving to one account —
// `sub` differs per app registration there. See lib/auth/identity.ts.
const userId = await resolveUserId({
iss,
sub,
oid: oidClaim(profile),
email: profile.email ?? null,
name: profile.name ?? null,
picture: (profile.picture as string | undefined) ?? null,
});
token.userId = userId;
token.sub = sub;
token.iss = iss;
// Sync group memberships from the OIDC `groups` claim. Missing or
// empty claim is treated as "user is in zero groups" — that path
// wipes the user's existing memberships, which is the conservative
// choice (don't keep stale grants alive if the IdP stopped
// asserting them).
//
// The whole profile goes in, not just `profile.groups`: an absent
// claim means one thing on its own and something else entirely next
// to EntraID's overage markers, and only the second case must abort.
// A GroupsOverageError thrown here fails the sign-in, which is the
// intent — it leaves the user's existing memberships untouched
// instead of silently deleting them.
if (userId) {
await syncUserGroupsFromClaim(userId, iss, profile);
}
}
return token;
},
async session({ session, token }) {
if (token.userId && typeof token.userId === "string") {
session.user = { ...session.user, id: token.userId };
}
return session;
},
},
});
// ---------- module augmentation: typed session.user.id ----------
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
export type { Session } from "next-auth";