Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { mapBetterAuthUserToPrincipal } from "./map-user-to-principal.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `map-user-to-principal`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Default Better-Auth `(user, session)` → `Principal` mapping consumed by both
|
||||
`createBetterAuthSession` and `createBetterAuthProvider`. Pure function; given
|
||||
the same view it always returns the same `Principal`. Operators MAY supply a
|
||||
custom `principalMapping` in `BetterAuthOptions` to project Better-Auth's
|
||||
user row onto a custom claims topology, but the default is good enough for
|
||||
most deployments — it drops sensitive fields, brands `id` and `scopes`, and
|
||||
prefers `session.activeOrganizationId` over `user.tenantId` so the
|
||||
organisation plugin's tenant scoping wins automatically when wired.
|
||||
|
||||
## Features
|
||||
|
||||
- `id` ← `user.id` branded as `PrincipalId`.
|
||||
- `scopes` ← `user.roles` branded as `Scope[]` (empty array when absent).
|
||||
- `tenantId` ← `session.activeOrganizationId` → `user.tenantId` fallback.
|
||||
- `claims` ← rest of `user` minus sensitive fields; `session_id` appended.
|
||||
- Throws `BetterAuthError(AUTH_BA.PRINCIPAL_MAPPING_FAILED)` on missing id.
|
||||
- Returned `Principal` and its `claims` are frozen.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { mapBetterAuthUserToPrincipal } from "@suaveplan/api-auth-better-auth/principal";
|
||||
|
||||
const principal = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", email: "a@b.c", roles: ["admin"] },
|
||||
session: { id: "s_1" },
|
||||
});
|
||||
// → { id: "u_1", scopes: ["admin"], claims: { id, email, roles, session_id } }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import type { PrincipalMapping } from "@suaveplan/api-auth-better-auth/options";
|
||||
|
||||
const customMapping: PrincipalMapping = ({ user, session }): Principal => ({
|
||||
id: `tenant_${session.activeOrganizationId}_${user.id}` as never,
|
||||
scopes: ((user.roles ?? []) as readonly string[]).flatMap((role) =>
|
||||
role === "admin"
|
||||
? ["billing:write", "billing:read", "users:write"]
|
||||
: [role]
|
||||
) as never[],
|
||||
claims: { email: user.email, source: "better-auth" },
|
||||
});
|
||||
|
||||
// Hand the custom mapping through BetterAuthOptions:
|
||||
// { ..., principalMapping: customMapping }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `mapBetterAuthUserToPrincipal(view)` | `PrincipalMapping` | Default mapping consumed by both factories |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The frozen-object return shape (`Object.freeze` on both the `Principal` and
|
||||
its `claims` bag) defends against handlers downstream mutating a single
|
||||
returned principal and observing the mutation on later requests — the same
|
||||
hardening applied by `@suaveplan/api-auth-static-principal`. The sensitive
|
||||
field list (`password`, `passwordHash`, `salt`, `twoFactorSecret`,
|
||||
`magicLinkToken`, `resetPasswordToken`) covers Better-Auth's stock plugin
|
||||
set; operators who define additional sensitive fields via a custom
|
||||
Better-Auth plugin SHOULD supply a custom `principalMapping` to drop those
|
||||
too. The mapping deliberately does NOT translate roles into a stable
|
||||
scope vocabulary — that's a deployment concern; the default is identity so
|
||||
the topology stays predictable.
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import { mapBetterAuthUserToPrincipal } from "./map-user-to-principal.js";
|
||||
|
||||
describe("mapBetterAuthUserToPrincipal", () => {
|
||||
it("maps a minimal user", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1" },
|
||||
session: {},
|
||||
});
|
||||
expect(p.id).toBe("u_1" as never);
|
||||
expect(p.scopes).toEqual([]);
|
||||
expect(p.claims).toEqual({ id: "u_1" });
|
||||
expect(p.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("translates roles into branded scopes", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", roles: ["admin", "billing:read", ""] },
|
||||
session: {},
|
||||
});
|
||||
// Empty role string is stripped.
|
||||
expect(p.scopes).toEqual(["admin", "billing:read"]);
|
||||
});
|
||||
|
||||
it("prefers session.activeOrganizationId over user.tenantId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", tenantId: "user-tenant" },
|
||||
session: { activeOrganizationId: "session-org" },
|
||||
});
|
||||
expect(p.tenantId).toBe("session-org" as never);
|
||||
});
|
||||
|
||||
it("falls back to user.tenantId when session has no org", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", tenantId: "user-tenant" },
|
||||
session: {},
|
||||
});
|
||||
expect(p.tenantId).toBe("user-tenant" as never);
|
||||
});
|
||||
|
||||
it("drops sensitive fields from claims", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: {
|
||||
id: "u_1",
|
||||
email: "x@y.z",
|
||||
passwordHash: "$argon2id$...",
|
||||
password: "leak",
|
||||
salt: "deadbeef",
|
||||
magicLinkToken: "tok",
|
||||
resetPasswordToken: "rst",
|
||||
twoFactorSecret: "secret",
|
||||
},
|
||||
session: {},
|
||||
});
|
||||
const claimKeys = Object.keys(p.claims);
|
||||
expect(claimKeys).toContain("email");
|
||||
expect(claimKeys).not.toContain("passwordHash");
|
||||
expect(claimKeys).not.toContain("password");
|
||||
expect(claimKeys).not.toContain("salt");
|
||||
expect(claimKeys).not.toContain("magicLinkToken");
|
||||
expect(claimKeys).not.toContain("resetPasswordToken");
|
||||
expect(claimKeys).not.toContain("twoFactorSecret");
|
||||
});
|
||||
|
||||
it("attaches session_id to claims when present", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1" },
|
||||
session: { id: "sess_xxx" },
|
||||
});
|
||||
expect(p.claims["session_id"]).toBe("sess_xxx");
|
||||
});
|
||||
|
||||
it("ignores empty session id", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1" },
|
||||
session: { id: "" },
|
||||
});
|
||||
expect(p.claims["session_id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores undefined user fields when building claims", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", email: undefined },
|
||||
session: {},
|
||||
});
|
||||
expect(p.claims["email"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats non-string roles as no roles", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", roles: null },
|
||||
session: {},
|
||||
});
|
||||
expect(p.scopes).toEqual([]);
|
||||
const p2 = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", roles: undefined },
|
||||
session: {},
|
||||
});
|
||||
expect(p2.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips non-string roles inside the array", { timeout: 5000 }, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", roles: ["admin", 5 as never, "ops"] },
|
||||
session: {},
|
||||
});
|
||||
expect(p.scopes).toEqual(["admin", "ops"]);
|
||||
});
|
||||
|
||||
it("treats empty tenantId strings as absent on both sides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1", tenantId: "" },
|
||||
session: { activeOrganizationId: "" },
|
||||
});
|
||||
expect(p.tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws AUTH_BA.PRINCIPAL_MAPPING_FAILED when id is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
mapBetterAuthUserToPrincipal({
|
||||
// Force missing-id at the runtime boundary even though the
|
||||
// type contract requires it — the mapper MUST guard.
|
||||
user: { id: undefined as never },
|
||||
session: {},
|
||||
});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect((err as BetterAuthError).code).toBe(
|
||||
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when id is an empty string", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
mapBetterAuthUserToPrincipal({
|
||||
user: { id: "" },
|
||||
session: {},
|
||||
})
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("freezes the returned principal and its claims", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = mapBetterAuthUserToPrincipal({
|
||||
user: { id: "u_1" },
|
||||
session: { id: "s_1" },
|
||||
});
|
||||
expect(Object.isFrozen(p)).toBe(true);
|
||||
expect(Object.isFrozen(p.claims)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user