Files

126 lines
4.6 KiB
TypeScript
Raw Permalink Normal View History

import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
/**
* Resolving OIDC claims to the internal `users.id`.
*
* Both surfaces come through here — the Web UI (`auth.ts`) and the MCP
* endpoint (`lib/mcp/context.ts`) — and that is the point. They each used to
* carry their own copy of this upsert, which is how the two drifted into
* disagreeing about who a user is.
*
* ## Why `oid` exists here
*
* Authentik's `sub` is `user.uid`, identical across every provider, so
* (iss, sub) identifies a person. EntraID's `sub` is PAIRWISE: Microsoft
* derives it from the token recipient, so the Web UI app registration and the
* MCP app registration emit different `sub` values for the same human. Keyed
* on `sub`, that person gets two rows — they sign into the Web UI, connect
* Claude Code, and find an empty account. Both paths upsert, so nothing
* errors; the split is completely silent.
*
* `oid` is the directory object id, which Microsoft documents as constant for
* a user across every application in a tenant. When it's present it wins.
* When it's absent (Authentik, Keycloak, Okta) behaviour is exactly as before.
*/
/** Extract a usable EntraID `oid` claim, or null on IdPs that don't emit one. */
export function oidClaim(claims: unknown): string | null {
const raw = (claims as { oid?: unknown } | null | undefined)?.oid;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : null;
}
export interface IdentityInput {
iss: string;
sub: string;
/** EntraID object id, or null. */
oid: string | null;
email: string | null;
name: string | null;
picture: string | null;
}
/** Postgres unique-violation. */
function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null)?.code === "23505";
}
/**
* Resolve (creating if needed) the `users` row for a set of verified claims.
*
* Resolution order when `oid` is present:
*
* 1. a row already keyed on this `oid` — the steady state
* 2. a pre-`oid` row for the same (iss, sub), which gets its `oid`
* backfilled in place. This is how a deployment that ran before the
* 0005 migration keeps its accounts instead of stranding them.
* 3. insert
*
* Without `oid` this collapses to the original (iss, sub) upsert.
*/
export async function resolveUserId(input: IdentityInput): Promise<string> {
const { iss, sub, oid, email, name, picture } = input;
const profile = { email, name, picture, lastSeenAt: new Date() };
if (oid) {
// 1. Steady state.
//
// Deliberately does NOT touch `oidc_sub`. The stored value is whichever
// app registration this person first arrived through; rewriting it on
// every request would flip it back and forth between the Web and MCP
// values and could collide with the (iss, sub) unique index.
const byOid = await db
.update(users)
.set(profile)
.where(and(eq(users.oidcIss, iss), eq(users.oidcOid, oid)))
.returning({ id: users.id });
if (byOid[0]) return byOid[0].id;
// 2. Adopt a row created before `oid` was recorded.
const adopted = await db
.update(users)
.set({ ...profile, oidcOid: oid })
.where(
and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), isNull(users.oidcOid)),
)
.returning({ id: users.id });
if (adopted[0]) return adopted[0].id;
}
// 3. Insert. The conflict target stays (iss, sub) because that is the index
// every row has; a concurrent writer racing us on `oid` instead is caught
// below.
try {
const inserted = await db
.insert(users)
.values({ oidcIss: iss, oidcSub: sub, oidcOid: oid, email, name, picture })
.onConflictDoUpdate({
target: [users.oidcIss, users.oidcSub],
set: profile,
})
.returning({ id: users.id });
if (inserted[0]) return inserted[0].id;
} catch (err) {
// Two requests for the same person arriving together through DIFFERENT
// app registrations: same `oid`, different `sub`, so the (iss, sub)
// conflict target doesn't fire and the partial (iss, oid) index rejects
// the loser. Fall through and read the winner's row.
if (!isUniqueViolation(err)) throw err;
}
const existing = await db
.select({ id: users.id })
.from(users)
.where(
oid
? and(eq(users.oidcIss, iss), eq(users.oidcOid, oid))
: and(eq(users.oidcIss, iss), eq(users.oidcSub, sub)),
)
.limit(1);
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
return existing[0].id;
}