Three defects stood between this codebase and a working Entra deployment.
All three fail silently, which is why they are grouped: each one masks the
next, and fixing any two still leaves a broken or dangerous install.
1. JWKS discovery. The key set URL was hardcoded to `${issuer}/jwks/`,
which is Authentik's convention, not a standard. Entra serves keys at
`/{tenant}/discovery/v2.0/keys`, so every Entra-issued MCP token failed
verification on a 404 — authentication was impossible, not merely
misconfigured. We now read `jwks_uri` from the issuer's discovery
document and fall back to the old path, so Authentik is untouched.
Discovery failure arms a 60s retry rather than pinning the wrong URL
for the life of the container.
2. Identity. Entra's `sub` is pairwise — derived from the token
recipient — so the Web UI and MCP app registrations emit different
`sub` values for the same human. Keyed on (iss, sub), that person got
two rows: sign into the Web UI, connect Claude Code, land in an empty
account. Both paths upsert, so nothing errored. Identity now keys on
`oid`, which Microsoft documents as constant across applications in a
tenant, via one resolver both surfaces share. Rows created before the
0005 migration adopt their `oid` on next sign-in.
3. Groups overage. Past 200 groups Entra omits `groups` entirely and
substitutes a `_claim_names` pointer. `normalizeGroupsClaim` read that
as "zero groups" and the sync deleted every membership the user had,
revoking access to every shared project on both surfaces with no error
raised. Both surfaces now refuse such a token instead — the Web UI
fails the sign-in, MCP returns 401 — leaving memberships intact and
naming the operator fix. An absent claim with no overage marker still
clears memberships, which is unchanged and deliberate.
Verified against a real pgvector instance: 66 tests pass, and reverting
either new behaviour fails exactly the tests that cover it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
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";
|