226 lines
6.7 KiB
TypeScript
226 lines
6.7 KiB
TypeScript
/**
|
|
* Phase 09 Task #82 — JIT-provisioning for Admin SSO.
|
|
*
|
|
* Lookup order:
|
|
* 1. AdminUser by (ssoProvider, ssoSubjectId)
|
|
* 2. AdminUser by linked User.email (case-insensitive) — legacy linking
|
|
* 3. New User + AdminUser if at least one allowed group is in claims
|
|
*
|
|
* Idempotent under concurrent logins thanks to the unique
|
|
* (ssoProvider, ssoSubjectId) constraint added in migration
|
|
* 20260517161240_09_admin_user_sso_fields.
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
import type { AdminSsoClaims, AdminSsoRole } from "./claims.server";
|
|
import { hasAllowedAdminGroup, mapGroupsToRole } from "./claims.server";
|
|
|
|
export const ADMIN_SSO_PROVIDER = "authentik";
|
|
|
|
export type JitProvisioningInput = {
|
|
readonly claims: AdminSsoClaims;
|
|
readonly now?: Date;
|
|
};
|
|
|
|
export type JitProvisionedAdmin = {
|
|
readonly userId: string;
|
|
readonly email: string;
|
|
readonly role: AdminSsoRole;
|
|
readonly created: boolean;
|
|
readonly linkedExisting: boolean;
|
|
};
|
|
|
|
export type JitProvisioningResult =
|
|
| { readonly ok: true; readonly admin: JitProvisionedAdmin }
|
|
| { readonly ok: false; readonly reason: "forbidden-group" };
|
|
|
|
export class JitProvisioningError extends Error {
|
|
readonly reason: "forbidden-group";
|
|
constructor(reason: "forbidden-group", message: string) {
|
|
super(message);
|
|
this.name = "JitProvisioningError";
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Narrow Prisma-shaped surface used by jitProvisionAdmin. Defining it as a
|
|
* structural type rather than importing PrismaClient keeps this module
|
|
* unit-testable with a hand-rolled fake.
|
|
*/
|
|
export type JitProvisioningDbClient = {
|
|
adminUser: {
|
|
findUnique: (args: { where: { ssoProvider_ssoSubjectId: { ssoProvider: string; ssoSubjectId: string } } }) =>
|
|
| Promise<({ userId: string } & AdminSsoUserRecord) | null>;
|
|
update: (args: {
|
|
where: { userId: string };
|
|
data: {
|
|
ssoProvider?: string;
|
|
ssoSubjectId?: string;
|
|
ssoGroups?: string[];
|
|
lastSsoLoginAt?: Date;
|
|
};
|
|
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
|
|
create: (args: {
|
|
data: {
|
|
userId: string;
|
|
ssoProvider: string;
|
|
ssoSubjectId: string;
|
|
ssoGroups: string[];
|
|
lastSsoLoginAt: Date;
|
|
};
|
|
}) => Promise<{ userId: string } & AdminSsoUserRecord>;
|
|
};
|
|
user: {
|
|
findUnique: (args: { where: { email: string } }) => Promise<{ id: string; email: string } | null>;
|
|
update: (args: { where: { id: string }; data: { firstName?: string; lastName?: string } }) => Promise<unknown>;
|
|
create: (args: {
|
|
data: {
|
|
email: string;
|
|
passwordHash: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
active: boolean;
|
|
};
|
|
}) => Promise<{ id: string; email: string }>;
|
|
};
|
|
};
|
|
|
|
type AdminSsoUserRecord = {
|
|
ssoProvider: string | null;
|
|
ssoSubjectId: string | null;
|
|
ssoGroups: string[];
|
|
lastSsoLoginAt: Date | null;
|
|
};
|
|
|
|
function splitName(fullName: string): { firstName: string; lastName: string } {
|
|
const cleaned = fullName.trim();
|
|
if (!cleaned) return { firstName: "", lastName: "" };
|
|
const parts = cleaned.split(/\s+/);
|
|
if (parts.length === 1) return { firstName: parts[0]!, lastName: "" };
|
|
return { firstName: parts[0]!, lastName: parts.slice(1).join(" ") };
|
|
}
|
|
|
|
/**
|
|
* Random throw-away password hash — placeholder so the User row can satisfy
|
|
* the existing non-null `passwordHash` constraint. SSO-only users never use
|
|
* it; they always authenticate through Authentik. Stored as an opaque
|
|
* 32-byte token prefixed to make the source obvious in audits.
|
|
*/
|
|
function generateSsoOnlyPasswordHash(): string {
|
|
return `sso-only!${randomBytes(32).toString("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* JIT-provision (or refresh) an AdminUser based on a verified Authentik
|
|
* claim bundle.
|
|
*/
|
|
export async function jitProvisionAdmin(
|
|
input: JitProvisioningInput,
|
|
db: JitProvisioningDbClient
|
|
): Promise<JitProvisioningResult> {
|
|
const { claims } = input;
|
|
const role = mapGroupsToRole(claims.groups);
|
|
if (!role || !hasAllowedAdminGroup(claims.groups)) {
|
|
return { ok: false, reason: "forbidden-group" };
|
|
}
|
|
const now = input.now ?? new Date();
|
|
const email = claims.email.toLowerCase();
|
|
|
|
// (1) Existing SSO-linked admin?
|
|
const existingBySso = await db.adminUser.findUnique({
|
|
where: { ssoProvider_ssoSubjectId: { ssoProvider: ADMIN_SSO_PROVIDER, ssoSubjectId: claims.subject } },
|
|
});
|
|
if (existingBySso) {
|
|
await db.adminUser.update({
|
|
where: { userId: existingBySso.userId },
|
|
data: {
|
|
ssoGroups: [...claims.groups],
|
|
lastSsoLoginAt: now,
|
|
},
|
|
});
|
|
const { firstName, lastName } = splitName(claims.name);
|
|
if (firstName || lastName) {
|
|
await db.user.update({ where: { id: existingBySso.userId }, data: { firstName, lastName } });
|
|
}
|
|
return {
|
|
ok: true,
|
|
admin: {
|
|
userId: existingBySso.userId,
|
|
email,
|
|
role,
|
|
created: false,
|
|
linkedExisting: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
// (2) Legacy admin matched by email — attach SSO fields.
|
|
const existingUserByEmail = await db.user.findUnique({ where: { email } });
|
|
if (existingUserByEmail) {
|
|
const updated = await db.adminUser.update({
|
|
where: { userId: existingUserByEmail.id },
|
|
data: {
|
|
ssoProvider: ADMIN_SSO_PROVIDER,
|
|
ssoSubjectId: claims.subject,
|
|
ssoGroups: [...claims.groups],
|
|
lastSsoLoginAt: now,
|
|
},
|
|
}).catch(async (err) => {
|
|
// If the user exists but has no AdminUser row, create one.
|
|
// Prisma's `update` throws P2025 for record-not-found.
|
|
const code = (err as { code?: string }).code;
|
|
if (code !== "P2025") throw err;
|
|
return db.adminUser.create({
|
|
data: {
|
|
userId: existingUserByEmail.id,
|
|
ssoProvider: ADMIN_SSO_PROVIDER,
|
|
ssoSubjectId: claims.subject,
|
|
ssoGroups: [...claims.groups],
|
|
lastSsoLoginAt: now,
|
|
},
|
|
});
|
|
});
|
|
return {
|
|
ok: true,
|
|
admin: {
|
|
userId: updated.userId,
|
|
email,
|
|
role,
|
|
created: false,
|
|
linkedExisting: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
// (3) Brand-new user + admin from SSO.
|
|
const { firstName, lastName } = splitName(claims.name);
|
|
const newUser = await db.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: generateSsoOnlyPasswordHash(),
|
|
firstName: firstName || "Admin",
|
|
lastName: lastName || "User",
|
|
active: true,
|
|
},
|
|
});
|
|
await db.adminUser.create({
|
|
data: {
|
|
userId: newUser.id,
|
|
ssoProvider: ADMIN_SSO_PROVIDER,
|
|
ssoSubjectId: claims.subject,
|
|
ssoGroups: [...claims.groups],
|
|
lastSsoLoginAt: now,
|
|
},
|
|
});
|
|
return {
|
|
ok: true,
|
|
admin: {
|
|
userId: newUser.id,
|
|
email,
|
|
role,
|
|
created: true,
|
|
linkedExisting: false,
|
|
},
|
|
};
|
|
}
|