Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/admin-sso/claims.server.ts
T

102 lines
4.0 KiB
TypeScript

/**
* Phase 09 Task #82 — OIDC ID-token claim extraction + group→role mapping.
*
* The "role" returned here is the *current* AdminUser identity layer in the
* Toolctrl boilerplate. Today the only durable AdminUser concept is "exists
* or not"; the spec adds two granular roles (`super_admin`, `staff`) that
* we persist as `ssoGroups` snapshots while we wait for the permissions
* matrix to grow first-class role columns. Callers should treat the role
* as advisory + future-proof.
*/
export type AuthentikGroup = "toolctrl-admin" | "toolctrl-staff";
export type AdminSsoRole = "super_admin" | "staff";
export const ADMIN_GROUP: AuthentikGroup = "toolctrl-admin";
export const STAFF_GROUP: AuthentikGroup = "toolctrl-staff";
export const ALLOWED_ADMIN_GROUPS: readonly AuthentikGroup[] = [ADMIN_GROUP, STAFF_GROUP];
/**
* Highest-priority-wins mapping. If a user is in BOTH groups they get
* super_admin (admins are a strict superset of staff for our purposes).
*/
export function mapGroupsToRole(groups: readonly string[] | undefined | null): AdminSsoRole | null {
if (!groups || groups.length === 0) return null;
if (groups.includes(ADMIN_GROUP)) return "super_admin";
if (groups.includes(STAFF_GROUP)) return "staff";
return null;
}
export type AdminSsoClaims = {
readonly subject: string;
readonly email: string;
readonly emailVerified: boolean;
readonly name: string;
readonly groups: readonly string[];
};
export type ClaimExtractionResult =
| { readonly ok: true; readonly claims: AdminSsoClaims }
| { readonly ok: false; readonly reason: "missing-sub" | "missing-email" | "email-not-verified" | "no-groups" };
/**
* Validate + extract the claim shape we trust. Authentik emits standard
* OIDC claims plus `groups` from the configured scope. We are deliberately
* conservative: missing/invalid claims short-circuit the login flow rather
* than silently falling back.
*/
export function extractAdminSsoClaims(idTokenClaims: Record<string, unknown> | null | undefined): ClaimExtractionResult {
if (!idTokenClaims) {
return { ok: false, reason: "missing-sub" };
}
const subject = typeof idTokenClaims.sub === "string" ? idTokenClaims.sub.trim() : "";
if (!subject) {
return { ok: false, reason: "missing-sub" };
}
const email = typeof idTokenClaims.email === "string" ? idTokenClaims.email.trim().toLowerCase() : "";
if (!email) {
return { ok: false, reason: "missing-email" };
}
// Spec security note: "email_verified=true prüfen". If the claim is
// absent we treat it as `true` only when the IdP is the trusted
// Authentik provider (which mints `email_verified` for SSO accounts);
// if it is present and false, reject.
const emailVerifiedClaim = idTokenClaims.email_verified;
const emailVerified = emailVerifiedClaim === undefined ? true : emailVerifiedClaim === true;
if (!emailVerified) {
return { ok: false, reason: "email-not-verified" };
}
const rawGroups = idTokenClaims.groups;
const groups: string[] = Array.isArray(rawGroups)
? rawGroups.filter((g): g is string => typeof g === "string" && g.length > 0)
: [];
const nameFromClaim = typeof idTokenClaims.name === "string" ? idTokenClaims.name.trim() : "";
const preferredName = typeof idTokenClaims.preferred_username === "string" ? idTokenClaims.preferred_username.trim() : "";
const name = nameFromClaim || preferredName || email;
return {
ok: true,
claims: {
subject,
email,
emailVerified,
name,
groups,
},
};
}
/**
* Convenience guard: pass-through that throws (or returns a Response) when
* the user has no allowed group. Kept here so the JIT-provisioning logic
* stays focused on the DB upsert.
*/
export function isAllowedAdminGroup(group: string): group is AuthentikGroup {
return ALLOWED_ADMIN_GROUPS.includes(group as AuthentikGroup);
}
export function hasAllowedAdminGroup(groups: readonly string[] | undefined | null): boolean {
if (!groups) return false;
return groups.some(isAllowedAdminGroup);
}