102 lines
4.0 KiB
TypeScript
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);
|
|
}
|