106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
import type {
|
|
Claims,
|
|
Principal,
|
|
PrincipalId,
|
|
Scope,
|
|
} from "@suaveplan/api-types/principal";
|
|
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
|
import type {
|
|
BetterAuthSessionRecord,
|
|
BetterAuthSessionView,
|
|
BetterAuthUserView,
|
|
PrincipalMapping,
|
|
} from "../options/options.types.js";
|
|
|
|
const SENSITIVE_USER_FIELDS = new Set([
|
|
"password",
|
|
"passwordHash",
|
|
"salt",
|
|
"twoFactorSecret",
|
|
"magicLinkToken",
|
|
"resetPasswordToken",
|
|
]);
|
|
|
|
/**
|
|
* Default Better-Auth user/session → `Principal` mapping. Produces a
|
|
* stable `Principal` carrying:
|
|
*
|
|
* - `id` ← `user.id` (branded as `PrincipalId`).
|
|
* - `scopes` ← `user.roles` (branded as `Scope[]`); empty array when
|
|
* no roles are attached. Plugins/middleware further upstream
|
|
* enrich the scope set; the mapper itself does no role-to-scope
|
|
* translation beyond identity.
|
|
* - `tenantId` ← `session.activeOrganizationId` (when present), else
|
|
* `user.tenantId` (when present), else `undefined`.
|
|
* - `claims` ← `user` minus sensitive fields plus a `session_id` claim
|
|
* carrying the Better-Auth session id (when present).
|
|
*
|
|
* Throws {@link BetterAuthError} with code
|
|
* `AUTH_BA.PRINCIPAL_MAPPING_FAILED` when `user.id` is missing — without a
|
|
* stable id the principal cannot be persisted across requests.
|
|
*/
|
|
export const mapBetterAuthUserToPrincipal: PrincipalMapping = (
|
|
view: BetterAuthSessionView
|
|
): Principal => {
|
|
const { user, session } = view;
|
|
if (typeof user.id !== "string" || user.id.length === 0) {
|
|
throw new BetterAuthError(
|
|
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
|
"Better-Auth user is missing a non-empty `id`",
|
|
{ context: { received: { idType: typeof user.id } } }
|
|
);
|
|
}
|
|
const id = user.id as unknown as PrincipalId;
|
|
const scopes = pickScopes(user.roles);
|
|
const tenantId = pickTenant(user, session);
|
|
const claims = buildClaims(user, session);
|
|
if (tenantId !== undefined) {
|
|
return Object.freeze({ id, scopes, tenantId, claims }) as Principal;
|
|
}
|
|
return Object.freeze({ id, scopes, claims }) as Principal;
|
|
};
|
|
|
|
function pickScopes(
|
|
roles: readonly string[] | null | undefined
|
|
): readonly Scope[] {
|
|
if (!Array.isArray(roles)) return [];
|
|
const out: Scope[] = [];
|
|
for (const role of roles) {
|
|
if (typeof role === "string" && role.length > 0) {
|
|
out.push(role as unknown as Scope);
|
|
}
|
|
}
|
|
return Object.freeze(out);
|
|
}
|
|
|
|
function pickTenant(
|
|
user: BetterAuthUserView,
|
|
session: BetterAuthSessionRecord
|
|
): import("@suaveplan/api-types/principal").Principal["tenantId"] | undefined {
|
|
const fromSession = session.activeOrganizationId;
|
|
if (typeof fromSession === "string" && fromSession.length > 0) {
|
|
return fromSession as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"];
|
|
}
|
|
const fromUser = user.tenantId;
|
|
if (typeof fromUser === "string" && fromUser.length > 0) {
|
|
return fromUser as unknown as import("@suaveplan/api-types/principal").Principal["tenantId"];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function buildClaims(
|
|
user: BetterAuthUserView,
|
|
session: BetterAuthSessionRecord
|
|
): Claims {
|
|
const claims: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(user)) {
|
|
if (SENSITIVE_USER_FIELDS.has(key)) continue;
|
|
if (value === undefined) continue;
|
|
claims[key] = value;
|
|
}
|
|
if (typeof session.id === "string" && session.id.length > 0) {
|
|
claims["session_id"] = session.id;
|
|
}
|
|
return Object.freeze(claims);
|
|
}
|