nestjs-slightly-better-auth 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/dist/testing/conformance.cjs +220 -76
- package/dist/testing/conformance.cjs.map +1 -1
- package/dist/testing/conformance.d.cts +158 -104
- package/dist/testing/conformance.d.cts.map +1 -1
- package/dist/testing/conformance.d.mts +158 -104
- package/dist/testing/conformance.d.mts.map +1 -1
- package/dist/testing/conformance.mjs +220 -76
- package/dist/testing/conformance.mjs.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -994,9 +994,23 @@ The principal-source kit passes each credential factory the instance the case ru
|
|
|
994
994
|
|
|
995
995
|
Every case boots real Nest applications or modules with a real Better Auth instance that includes `conformanceProbePlugin()`. The probe counts and faults every operation of the instance's database adapter, so storage outages reach endpoints that would swallow them, and records the `Set-Cookie` lines each call produces. `createConformanceAuth({ plugins })` builds such an instance: memory adapter, `testUtils()`, `bearer()`, the probe plugin, `nestjs()` last, and `session.updateAge: 0` and origin checks on (`advanced.disableOriginCheck: false` and `advanced.disableCSRFCheck: false`), whatever `options` says. The principal-source and policy kits take that instance as `auth`; the principal-source kit rejects an instance without `session.updateAge: 0`. The transport kit creates its own instance, with the organization plugin and the organizations its `org` fixture names, and passes it to your `createApp` harness together with the kit's API-key source in `principals`, a `logger` to boot with, and the `globalScope`, `appEnhancers`, `forwardDirectCalls`, `fieldResolverEnhancers`, `fieldResolverCoverage`, `federation` and `graphqlContext` settings of each boot. Apply every fixture decorator, including the kit's `SetMetadata()` marker, which maps the transport's claims to fixtures, and the class decorators of `inherited.subclasses` and `graph.classDecorators`. On a connection-shaped browser leg, the kit's cookie credential carries the instance's base URL, `http://localhost:3000`, as `Origin`, as a browser page of that origin does; the `T-ws-origin-*` cases pass their own `Origin`, `Host` and forwarding headers, which the harness sends in the handshake.
|
|
996
996
|
|
|
997
|
-
The policy kit judges principals through the real evaluator, and cases that need route planning, acceptance or principal resolution send in-process requests through the real `BetterAuthGuard`. Pass the sources of the requirement's other principal kinds in `sources` (for example `apiKeyPrincipal()`). Unit-specific cases run when the requirement contains the built-in admin policy (`Z-admin-*`), an organization policy (`Z-org-ref-types` and the organization row of `Z-apikey-quota-per-request`) or the API-key permission policy (its `Z-apikey-quota-per-request` row), and skip with the reason otherwise. They use the instance's configuration where it matters: `session.cookieCache.enabled` for the warm-cache rows and `apiKey({ enableSessionForAPIKeys: true })` for the API-key session and quota rows. The kit builds the instances that need another configuration (`customSession` role shapes, `adminUserIds`, a dynamic `baseURL`) with `createConformanceAuth()`.
|
|
997
|
+
The policy kit judges principals through the real evaluator, and cases that need route planning, acceptance or principal resolution send in-process requests through the real `BetterAuthGuard`. Pass the sources of the requirement's other principal kinds in `sources` (for example `apiKeyPrincipal()`). Unit-specific cases run when the requirement contains the built-in admin policy (`Z-admin-*`), an organization policy (`Z-org-ref-types` and the organization row of `Z-apikey-quota-per-request`) or the API-key permission policy (its `Z-apikey-quota-per-request` row), and skip with the reason otherwise. They use the instance's configuration where it matters: `session.cookieCache.enabled` for the warm-cache rows and `apiKey({ enableSessionForAPIKeys: true })` for the API-key session and quota rows. The kit builds the instances that need another configuration (`customSession` role shapes, `adminUserIds`, a dynamic `baseURL`, an instance without `apiKey()`) with `variant(overrides)`, which defaults to `createConformanceAuth(overrides)`. A storage job passes a variant that builds the same instance on its own database: add what `createConformanceAuth()` adds (`testUtils()`, `bearer()`, `conformanceProbePlugin()`, `nestjs()` last, `session.updateAge: 0` and `advanced.disableOriginCheck: false`), and migrate the schema before returning it.
|
|
998
998
|
|
|
999
|
-
|
|
999
|
+
`deliveries` also sends the transport-specific rows through real transports. A `PolicyDelivery` has these members:
|
|
1000
|
+
|
|
1001
|
+
- `name` names the delivery in case titles.
|
|
1002
|
+
- `createApp(handlers, auth, { principals, logger })` boots an app with `globalGuard` that exposes each kit handler (a `FixtureHandler`, as in `transportConformance`) under its name: a gateway message with explicit coverage, a message pattern or a GraphQL query field. The kit's only named input, `orgId`, keeps its JSON type; a GraphQL harness declares it as a JSON scalar argument.
|
|
1003
|
+
- `invoke(app, handler, headers, input)` sends one logical request.
|
|
1004
|
+
- Connection transports add `connect(app, headers)`, which opens one connection whose `invoke()` sends one message, or one subscription operation for GraphQL, after the previous one answered. They also add `connectionPrincipalTtlMs`, the transport's `principalTtlMs` or `subscriptionPrincipalTtlMs`.
|
|
1005
|
+
|
|
1006
|
+
The kit sends the probe plugin's trusted `Origin` with every delivered request. For each delivery it adds these cases, titled `<name>: …`:
|
|
1007
|
+
|
|
1008
|
+
- `Z-admin-rejects-api-key-session`
|
|
1009
|
+
- `Z-org-ref-types`, with junk `orgId` values in the transport's payload
|
|
1010
|
+
- `Z-apikey-quota-per-request`, the API-key session setup
|
|
1011
|
+
- `Z-admin-banned` on one connection. It runs when `connect` and a positive `connectionPrincipalTtlMs` are present, and first checks that a default-freshness handler reuses the connection's principal after sign-out.
|
|
1012
|
+
|
|
1013
|
+
This repository runs the platform kit against Express and Fastify, including the Fastify HTTP/2 adapter for the HTTP/2 rows; the transport kit against the built-in HTTP transport, an in-process reference transport, Socket.IO and `ws` (each with and without a principal TTL), the RPC transport over TCP (standalone and hybrid), gRPC, NATS, RabbitMQ, MQTT, Redis and Kafka, and the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws (with and without a subscription principal TTL; Mercurius also without `mercuriusSubscriptionContext()`), with federation 2 subgraphs and a federation 1 run of `T-reference-resolver`; the principal-source kit against the session source (its `S-bridge-*` rows on Express and Fastify), the API-key source with user-owned and organization-owned keys, and the design's JWT source sketch with derived and explicit claims; and the policy kit against the admin, organization (also with `customSession`), API-key and fresh-session policies. The policy kit's delivery rows run over Socket.IO and `ws` with a 300 s principal TTL, over RPC over TCP (standalone and hybrid), and over the Apollo (Express and Fastify) and Mercurius transports over HTTP and over graphql-ws with a 300 s subscription principal TTL. `Z-admin-null-role` also runs on PGlite with Better Auth's Kysely adapter and migrations. gRPC skips `T-error-shape`, because it delivers only a status and a message. The kit reads each call's browser leg while its invocation runs, because the GraphQL transports reject a completed request. Mutation tests confirm that each of these defects fails the intended case: a platform merging `Set-Cookie`, handing Better Auth no client IP, ignoring its proxy trust, dropping the request host or stripping forwarding headers, a kernel that unbinds hooks on `close()`, reports taking over a closed binding or keeps a microservice's binding open, a transport sharing invocation state across aliases, a transport without a browser leg on safe operations, a `defineExtension()` transport with a lineage or nesting and no helper, a WebSocket transport that enforces its handshake like a safe read, exempts handshakes that carry a token, builds its leg URL from the bare handshake path or from forwarded headers, or answers internal errors with their cause, a GraphQL transport whose aliases share one invocation, whose lineage gives every field of an operation one position, whose field resolvers do not inherit their root's access, or that serves reference resolvers without field guards, a GraphQL harness that omits `invokeGraph`, `invokeEntities` or `invokeConnection`, a transport that claims every context type, runs no metadata canary, claims classes without handlers or drops the `Host` header without declaring hostless calls, a planner that caches plans by handler function, ignores `@SkipOriginCheck()` or skips the origin check of safe operations, an HTTP transport whose invocation is not the handler's request (Fastify), a WebSocket transport whose messages on one socket share their invocation, a GraphQL transport that treats a completed request as live, ignores `connectionParams` credentials, treats socket operations as safe reads, exempts `connectionParams` tokens, serves inherited forwarding fields without field guards, omits `W_FIELD_RESOLVER_INHERITS` or `I_CLASS_METADATA_OPERATIONS_ONLY`, a planner that applies class-level acceptance to field resolvers or records a reading on a form-mode nested public field, a source throwing its denial, a source dropping its refresh cookie, a source turning a storage outage into a 401, API-key sources that report every failure as an invalid key, verify once per call, run without the outage probe or latency signal, probe on every invalid key, touch key rows, or map organization keys to users, a session source that trusts hook answers on authoritative routes, a source that wraps errors without the request's credentials, JWT sources that expect the basePath URL as issuer or deny a key-set outage, sources that drop the host or forwarded headers, a route planner that forwards every handler's direct calls, a `forwardForeignCookies()` without a declaration check or without forwarding, a policy widening delegated principals, a policy turning a lost session or a storage outage into a denial, an organization policy coercing non-string inputs (also over `ws`) or repeating its Better Auth call, admin policies that admit API keys by default, lack fresh identity (also over RPC) or hostless calls, ignore a key owner's ban, judge the session's role or trust the stored user row (also on PGlite), a request scope that reuses a connection's principal for authoritative handlers (Socket.IO, graphql-ws), a WebSocket transport that declares a principal TTL it does not apply, a graphql-ws delivery that opens a new socket for each operation, and boot validation or route planning that ignore request-scoped dependencies or a class policy's `requires` field.
|
|
1000
1014
|
|
|
1001
1015
|
## Develop in this monorepo
|
|
1002
1016
|
|
|
@@ -2152,6 +2152,10 @@ async function bridgeOf(auth) {
|
|
|
2152
2152
|
}
|
|
2153
2153
|
//#endregion
|
|
2154
2154
|
//#region src/conformance-policy-harness.ts
|
|
2155
|
+
/** An instance with the given overrides, built by options.variant() or createConformanceAuth(). */
|
|
2156
|
+
async function variantOf$1(options, overrides) {
|
|
2157
|
+
return options.variant ? await options.variant(overrides) : createConformanceAuth(overrides);
|
|
2158
|
+
}
|
|
2155
2159
|
function requirementsOf(expression) {
|
|
2156
2160
|
return "anyOf" in expression ? expression.anyOf.flatMap(requirementsOf) : "allOf" in expression ? expression.allOf.flatMap(requirementsOf) : [expression];
|
|
2157
2161
|
}
|
|
@@ -2346,7 +2350,6 @@ async function requestHarness(auth, options) {
|
|
|
2346
2350
|
return {
|
|
2347
2351
|
probe,
|
|
2348
2352
|
logger,
|
|
2349
|
-
moduleRef,
|
|
2350
2353
|
async request(route, headers, input = {}) {
|
|
2351
2354
|
const key = {};
|
|
2352
2355
|
const args = [{
|
|
@@ -2386,8 +2389,61 @@ async function requestHarness(auth, options) {
|
|
|
2386
2389
|
close: () => moduleRef.close()
|
|
2387
2390
|
};
|
|
2388
2391
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
2392
|
+
/** A delivery's answer as a request outcome: its status code (500 when it carries none), code and reason. */
|
|
2393
|
+
function deliveredOutcome(result) {
|
|
2394
|
+
if (result.ok) return {
|
|
2395
|
+
ok: true,
|
|
2396
|
+
status: 200
|
|
2397
|
+
};
|
|
2398
|
+
return {
|
|
2399
|
+
ok: false,
|
|
2400
|
+
status: result.error?.statusCode ?? 500,
|
|
2401
|
+
code: result.error?.code,
|
|
2402
|
+
reason: result.error?.reason,
|
|
2403
|
+
...result.error?.statusCode === void 0 ? { error: result.error?.message } : {}
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
/** The kit's routes as the delivery's handlers: no parameters, a safe operation, answering `{ ok: true }`. */
|
|
2407
|
+
function deliveryHandlers(routes) {
|
|
2408
|
+
return Object.fromEntries(Object.entries(routes).map(([name, decorators]) => [name, {
|
|
2409
|
+
decorators,
|
|
2410
|
+
params: [],
|
|
2411
|
+
operation: "read",
|
|
2412
|
+
handle: () => ({ ok: true })
|
|
2413
|
+
}]));
|
|
2414
|
+
}
|
|
2415
|
+
/** The request harness over a PolicyDelivery's real transport. */
|
|
2416
|
+
async function deliveryHarness(delivery, auth, options) {
|
|
2417
|
+
if (options.providers?.length || options.session !== void 0) throw new require_auth_errors.BetterAuthConfigurationError("CONFORMANCE_DELIVERY_OPTIONS", "A policy delivery boots its own application: it takes routes and principal sources only.");
|
|
2418
|
+
const probe = await probeOf(auth);
|
|
2419
|
+
const logger = options.logger ?? new CapturingLogger();
|
|
2420
|
+
const app = await delivery.createApp(deliveryHandlers(options.routes), auth, {
|
|
2421
|
+
principals: options.sources ?? [],
|
|
2422
|
+
logger
|
|
2423
|
+
});
|
|
2424
|
+
const connect = delivery.connect?.bind(delivery);
|
|
2425
|
+
const browser = (headers) => {
|
|
2426
|
+
const values = new Headers(headers);
|
|
2427
|
+
if (!values.has("origin")) values.set("origin", PROBE_TRUSTED_ORIGIN);
|
|
2428
|
+
return values;
|
|
2429
|
+
};
|
|
2430
|
+
return {
|
|
2431
|
+
probe,
|
|
2432
|
+
logger,
|
|
2433
|
+
request: async (route, headers, input = {}) => deliveredOutcome(await delivery.invoke(app, route, browser(headers), input)),
|
|
2434
|
+
...connect ? { async connect(headers) {
|
|
2435
|
+
const connection = await connect(app, browser(headers));
|
|
2436
|
+
return {
|
|
2437
|
+
request: async (route, input = {}) => deliveredOutcome(await connection.invoke(route, input)),
|
|
2438
|
+
close: () => connection.close()
|
|
2439
|
+
};
|
|
2440
|
+
} } : {},
|
|
2441
|
+
close: () => app.close()
|
|
2442
|
+
};
|
|
2443
|
+
}
|
|
2444
|
+
/** Runs `fn` with requests through the in-process guard, or through `delivery`'s transport when given. */
|
|
2445
|
+
async function withRequests(auth, options, fn, delivery) {
|
|
2446
|
+
const value = delivery ? await deliveryHarness(delivery, auth, options) : await requestHarness(auth, options);
|
|
2391
2447
|
try {
|
|
2392
2448
|
return await fn(value);
|
|
2393
2449
|
} finally {
|
|
@@ -2584,7 +2640,7 @@ function assertDenied(outcome, status, reason, message) {
|
|
|
2584
2640
|
/**
|
|
2585
2641
|
* The unit-specific rows of design §14.1 and the core class-policy row. Each unit's cases run when the requirement
|
|
2586
2642
|
* contains that built-in policy, and skip with the reason otherwise; instances the case needs in another configuration
|
|
2587
|
-
* (customSession shapes, adminUserIds, a dynamic baseURL) are built by
|
|
2643
|
+
* (customSession shapes, adminUserIds, a dynamic baseURL) are built by options.variant(), createConformanceAuth() by default.
|
|
2588
2644
|
*/
|
|
2589
2645
|
function unitPolicyCases(options, known) {
|
|
2590
2646
|
const { auth } = options;
|
|
@@ -2595,6 +2651,152 @@ function unitPolicyCases(options, known) {
|
|
|
2595
2651
|
const noOrg = orgLeaf ? void 0 : "the requirement contains no built-in organization policy";
|
|
2596
2652
|
const admin_ = adminPolicy;
|
|
2597
2653
|
const adminCase = (id, title, run, skip = noAdmin) => conformanceCase(id, title, run, skip);
|
|
2654
|
+
/** Z-admin-rejects-api-key-session through the in-process guard, or through `delivery`'s transport. */
|
|
2655
|
+
const rejectsApiKeySession = async (delivery) => {
|
|
2656
|
+
if (!await hasPlugin(auth, "api-key")) return conformanceSkip("the instance has no apiKey() plugin");
|
|
2657
|
+
const adminId = await kitUser(auth, { role: "admin" });
|
|
2658
|
+
const otherId = await kitUser(auth, { role: "user" });
|
|
2659
|
+
const key = await createKey$1(auth, adminId, { permissions: { project: ["read"] } });
|
|
2660
|
+
if (!await keySessions(auth, key.key)) return conformanceSkip("the instance's apiKey() plugin does not set enableSessionForAPIKeys (or reads no x-api-key header)");
|
|
2661
|
+
const keyHeaders = new Headers({ [SDK_KEY_HEADER]: key.key });
|
|
2662
|
+
node_assert_strict.default.equal(await sdkStatus(() => api(auth).listUsers({
|
|
2663
|
+
headers: keyHeaders,
|
|
2664
|
+
query: {}
|
|
2665
|
+
})), 401, "Better Auth's listUsers accepted an API-key session");
|
|
2666
|
+
node_assert_strict.default.equal(await sdkStatus(() => api(auth).banUser({
|
|
2667
|
+
headers: keyHeaders,
|
|
2668
|
+
body: { userId: otherId }
|
|
2669
|
+
})), 401, "Better Auth's banUser accepted an API-key session");
|
|
2670
|
+
const cookie = (await sessionHeaders(auth, adminId)).get("cookie");
|
|
2671
|
+
return withRequests(auth, { routes: {
|
|
2672
|
+
ban: [require_auth_decorators.Require(adminRequirement(admin_, { user: ["ban"] }))],
|
|
2673
|
+
authoritative: [require_auth_decorators.RequireAuth({ authoritative: true })],
|
|
2674
|
+
ordinary: [require_auth_decorators.RequireAuth()]
|
|
2675
|
+
} }, async ({ request }) => {
|
|
2676
|
+
assertDenied(await request("ban", keyHeaders), 401, void 0, "x-api-key alone on @RequirePermission({ user: ['ban'] })");
|
|
2677
|
+
assertDenied(await request("authoritative", keyHeaders), 401, void 0, "x-api-key alone on @RequireAuth({ authoritative: true })");
|
|
2678
|
+
const ordinary = await request("ordinary", keyHeaders);
|
|
2679
|
+
node_assert_strict.default.equal(ordinary.ok, true, `a default-freshness route rejected the key session: ${describeOutcome(ordinary)}`);
|
|
2680
|
+
assertDenied(await request("authoritative", {
|
|
2681
|
+
cookie,
|
|
2682
|
+
[SDK_KEY_HEADER]: key.key
|
|
2683
|
+
}), 401, void 0, "the admin's cookie with the key on an authoritative route (RK27)");
|
|
2684
|
+
}, delivery);
|
|
2685
|
+
};
|
|
2686
|
+
/**
|
|
2687
|
+
* Z-org-ref-types' input rows through the in-process guard, or through `delivery`'s transport: junk `orgId` values are
|
|
2688
|
+
* denied ORGANIZATION_REQUIRED without a Better Auth call and without an error log, the owner's ID is allowed.
|
|
2689
|
+
*/
|
|
2690
|
+
const orgRefInputs = async (leaf, delivery) => {
|
|
2691
|
+
const ownerId = await kitUser(auth);
|
|
2692
|
+
const headers = await sessionHeaders(auth, ownerId);
|
|
2693
|
+
const organizationId = await createOrganizationFor(auth, headers);
|
|
2694
|
+
await withRequests(auth, { routes: { org: [require_auth_decorators.Require(withOrganization(leaf, require_organization.fromParam("orgId")))] } }, async ({ request, probe, logger }) => {
|
|
2695
|
+
for (const value of [
|
|
2696
|
+
{ $ne: null },
|
|
2697
|
+
123,
|
|
2698
|
+
["a"],
|
|
2699
|
+
""
|
|
2700
|
+
]) {
|
|
2701
|
+
const from = probe.calls.length;
|
|
2702
|
+
assertDenied(await request("org", headers, { orgId: value }), 403, "ORGANIZATION_REQUIRED", `orgId ${JSON.stringify(value)}`);
|
|
2703
|
+
node_assert_strict.default.deepEqual(betterAuthCalls(probe, from), [], `orgId ${JSON.stringify(value)} reached Better Auth`);
|
|
2704
|
+
}
|
|
2705
|
+
const valid = await request("org", headers, { orgId: organizationId });
|
|
2706
|
+
node_assert_strict.default.equal(valid.ok, true, `the owner's organization ID was denied: ${describeOutcome(valid)}`);
|
|
2707
|
+
node_assert_strict.default.deepEqual(logger.errors().map((entry) => entry.text), [], "a rejected organization input was logged as an error");
|
|
2708
|
+
}, delivery);
|
|
2709
|
+
return {
|
|
2710
|
+
headers,
|
|
2711
|
+
organizationId
|
|
2712
|
+
};
|
|
2713
|
+
};
|
|
2714
|
+
const quotaRoutes = () => {
|
|
2715
|
+
const permission = require_organization.orgPermission({ organization: ["update"] }, { organization: require_organization.fromParam("orgId") });
|
|
2716
|
+
const member = require_organization.orgMember({ organization: require_organization.fromParam("orgId") });
|
|
2717
|
+
return {
|
|
2718
|
+
routes: { quota: [require_auth_decorators.Require(permission, member)] },
|
|
2719
|
+
permission,
|
|
2720
|
+
member
|
|
2721
|
+
};
|
|
2722
|
+
};
|
|
2723
|
+
/**
|
|
2724
|
+
* Z-apikey-quota-per-request's first setup through the in-process guard, or through `delivery`'s transport: an API-key
|
|
2725
|
+
* session on two org policies spends the key's quota 3 times per request. Returns the skip when the instance cannot.
|
|
2726
|
+
*/
|
|
2727
|
+
const quotaSpend = async (delivery) => {
|
|
2728
|
+
if (!await hasPlugin(auth, "api-key")) return conformanceSkip("the instance has no apiKey() plugin");
|
|
2729
|
+
const ownerId = await kitUser(auth);
|
|
2730
|
+
const headers = await sessionHeaders(auth, ownerId);
|
|
2731
|
+
const organizationId = await createOrganizationFor(auth, headers);
|
|
2732
|
+
const key = await createKey$1(auth, ownerId, { remaining: 100 });
|
|
2733
|
+
if (!await keySessions(auth, key.key)) return conformanceSkip("the instance's apiKey() plugin does not set enableSessionForAPIKeys (or reads no x-api-key header)");
|
|
2734
|
+
return withRequests(auth, { routes: quotaRoutes().routes }, async ({ request }) => {
|
|
2735
|
+
const before = await remainingOf(auth, key.id);
|
|
2736
|
+
const outcome = await request("quota", { [SDK_KEY_HEADER]: key.key }, { orgId: organizationId });
|
|
2737
|
+
node_assert_strict.default.equal(outcome.ok, true, `the key owner's organization route was denied: ${describeOutcome(outcome)}`);
|
|
2738
|
+
node_assert_strict.default.equal(before - await remainingOf(auth, key.id), 3, "one request with two credential-presenting org policies must spend 3");
|
|
2739
|
+
}, delivery);
|
|
2740
|
+
};
|
|
2741
|
+
/**
|
|
2742
|
+
* Z-admin-banned on one connection of `delivery`, whose connections reuse their principal for its declared TTL: the
|
|
2743
|
+
* connection keeps a signed-out user's principal for a default-freshness handler, while the authoritative admin handler
|
|
2744
|
+
* denies a banned or signed-out admin with a warm cookie cache on the connection's next message.
|
|
2745
|
+
*/
|
|
2746
|
+
const bannedOnConnection = async (delivery) => {
|
|
2747
|
+
if (!await cookieCacheEnabled(auth)) return conformanceSkip("the instance's session.cookieCache is off; run the kit on an instance with session.cookieCache.enabled");
|
|
2748
|
+
const sdk = await sdkContext(auth);
|
|
2749
|
+
const banning = await sessionHeaders(auth, await kitUser(auth, { role: "admin" }));
|
|
2750
|
+
const ttl = delivery.connectionPrincipalTtlMs;
|
|
2751
|
+
return withRequests(auth, { routes: {
|
|
2752
|
+
list: [require_auth_decorators.Require(adminRequirement(admin_, { user: ["list"] }))],
|
|
2753
|
+
ordinary: [require_auth_decorators.RequireAuth()]
|
|
2754
|
+
} }, async ({ connect }) => {
|
|
2755
|
+
const open = connect;
|
|
2756
|
+
const reusing = await kitUser(auth, { role: "admin" });
|
|
2757
|
+
const connection = await open(await sessionHeaders(auth, reusing));
|
|
2758
|
+
try {
|
|
2759
|
+
const first = await connection.request("ordinary");
|
|
2760
|
+
node_assert_strict.default.equal(first.ok, true, `the connection's first message was denied: ${describeOutcome(first)}`);
|
|
2761
|
+
await sdk.internalAdapter.deleteUserSessions(reusing);
|
|
2762
|
+
const reused = await connection.request("ordinary");
|
|
2763
|
+
node_assert_strict.default.equal(reused.ok, true, `the connection did not reuse its principal within its declared ${ttl} ms TTL: ${describeOutcome(reused)}`);
|
|
2764
|
+
assertDenied(await connection.request("list"), 401, void 0, "an authoritative handler reused the connection's principal after sign-out");
|
|
2765
|
+
} finally {
|
|
2766
|
+
await connection.close();
|
|
2767
|
+
}
|
|
2768
|
+
for (const variant of ["banned", "signed out everywhere"]) {
|
|
2769
|
+
const userId = await kitUser(auth, { role: "admin" });
|
|
2770
|
+
const socket = await open(await sessionHeaders(auth, userId, { warmCache: true }));
|
|
2771
|
+
try {
|
|
2772
|
+
for (const route of ["list", "ordinary"]) {
|
|
2773
|
+
const before = await socket.request(route);
|
|
2774
|
+
node_assert_strict.default.equal(before.ok, true, `${variant}: ${route} denied the admin before: ${describeOutcome(before)}`);
|
|
2775
|
+
}
|
|
2776
|
+
if (variant === "banned") await api(auth).banUser({
|
|
2777
|
+
headers: banning,
|
|
2778
|
+
body: { userId }
|
|
2779
|
+
});
|
|
2780
|
+
else await sdk.internalAdapter.deleteUserSessions(userId);
|
|
2781
|
+
assertDenied(await socket.request("list"), 401, void 0, `${variant}: the connection's next message still admitted the admin`);
|
|
2782
|
+
} finally {
|
|
2783
|
+
await socket.close();
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
}, delivery);
|
|
2787
|
+
};
|
|
2788
|
+
/** The delivery variants of the transport-specific rows, titled with the delivery's name. */
|
|
2789
|
+
const deliveryCases = (delivery) => {
|
|
2790
|
+
const title = (text) => `${delivery.name}: ${text}`;
|
|
2791
|
+
return [
|
|
2792
|
+
adminCase("Z-admin-rejects-api-key-session", title("an admin's API-key session is rejected on admin and authoritative handlers and accepted on default-freshness handlers"), () => rejectsApiKeySession(delivery)),
|
|
2793
|
+
adminCase("Z-admin-banned", title("a banned or signed-out admin with a warm cookie cache is denied on the connection's next message despite its principal TTL"), () => bannedOnConnection(delivery), noAdmin ?? (!delivery.connect ? "the delivery opens no connections (connect())" : !(delivery.connectionPrincipalTtlMs > 0) ? "the delivery's connections reuse no principal (connectionPrincipalTtlMs); the row applies to a principal TTL" : void 0)),
|
|
2794
|
+
conformanceCase("Z-org-ref-types", title("fromParam rejects non-string organization inputs without a Better Auth call"), async () => {
|
|
2795
|
+
await orgRefInputs(orgLeaf, delivery);
|
|
2796
|
+
}, noOrg),
|
|
2797
|
+
conformanceCase("Z-apikey-quota-per-request", title("an API-key session on org policies spends 3 per request"), () => quotaSpend(delivery), noOrg)
|
|
2798
|
+
];
|
|
2799
|
+
};
|
|
2598
2800
|
return [
|
|
2599
2801
|
adminCase("Z-admin-rejects-api-key", "an admin-owned API-key principal is denied PRINCIPAL_NOT_SUPPORTED without a Better Auth call, and 401 on a request", async () => {
|
|
2600
2802
|
const principal = await options.delegatedPrincipal();
|
|
@@ -2626,37 +2828,7 @@ function unitPolicyCases(options, known) {
|
|
|
2626
2828
|
node_assert_strict.default.equal(keys.verifications, 0, "the route verified an API key it does not accept");
|
|
2627
2829
|
});
|
|
2628
2830
|
}, noAdmin ?? (options.delegatedPrincipal ? void 0 : "the options give no delegatedPrincipal()")),
|
|
2629
|
-
adminCase("Z-admin-rejects-api-key-session", "an admin's API-key session is rejected on admin and authoritative routes and accepted on default-freshness routes",
|
|
2630
|
-
if (!await hasPlugin(auth, "api-key")) return conformanceSkip("the instance has no apiKey() plugin");
|
|
2631
|
-
const adminId = await kitUser(auth, { role: "admin" });
|
|
2632
|
-
const otherId = await kitUser(auth, { role: "user" });
|
|
2633
|
-
const key = await createKey$1(auth, adminId, { permissions: { project: ["read"] } });
|
|
2634
|
-
if (!await keySessions(auth, key.key)) return conformanceSkip("the instance's apiKey() plugin does not set enableSessionForAPIKeys (or reads no x-api-key header)");
|
|
2635
|
-
const keyHeaders = new Headers({ [SDK_KEY_HEADER]: key.key });
|
|
2636
|
-
node_assert_strict.default.equal(await sdkStatus(() => api(auth).listUsers({
|
|
2637
|
-
headers: keyHeaders,
|
|
2638
|
-
query: {}
|
|
2639
|
-
})), 401, "Better Auth's listUsers accepted an API-key session");
|
|
2640
|
-
node_assert_strict.default.equal(await sdkStatus(() => api(auth).banUser({
|
|
2641
|
-
headers: keyHeaders,
|
|
2642
|
-
body: { userId: otherId }
|
|
2643
|
-
})), 401, "Better Auth's banUser accepted an API-key session");
|
|
2644
|
-
const cookie = (await sessionHeaders(auth, adminId)).get("cookie");
|
|
2645
|
-
return withRequests(auth, { routes: {
|
|
2646
|
-
ban: [require_auth_decorators.Require(adminRequirement(admin_, { user: ["ban"] }))],
|
|
2647
|
-
authoritative: [require_auth_decorators.RequireAuth({ authoritative: true })],
|
|
2648
|
-
ordinary: [require_auth_decorators.RequireAuth()]
|
|
2649
|
-
} }, async ({ request }) => {
|
|
2650
|
-
assertDenied(await request("ban", keyHeaders), 401, void 0, "x-api-key alone on @RequirePermission({ user: ['ban'] })");
|
|
2651
|
-
assertDenied(await request("authoritative", keyHeaders), 401, void 0, "x-api-key alone on @RequireAuth({ authoritative: true })");
|
|
2652
|
-
const ordinary = await request("ordinary", keyHeaders);
|
|
2653
|
-
node_assert_strict.default.equal(ordinary.ok, true, `a default-freshness route rejected the key session: ${describeOutcome(ordinary)}`);
|
|
2654
|
-
assertDenied(await request("authoritative", {
|
|
2655
|
-
cookie,
|
|
2656
|
-
[SDK_KEY_HEADER]: key.key
|
|
2657
|
-
}), 401, void 0, "the admin's cookie with the key on an authoritative route (RK27)");
|
|
2658
|
-
});
|
|
2659
|
-
}),
|
|
2831
|
+
adminCase("Z-admin-rejects-api-key-session", "an admin's API-key session is rejected on admin and authoritative routes and accepted on default-freshness routes", () => rejectsApiKeySession()),
|
|
2660
2832
|
adminCase("Z-admin-banned", "a banned or signed-out admin with a warm cookie cache is denied on the next request; a banned key owner until banExpires", async () => {
|
|
2661
2833
|
if (!await cookieCacheEnabled(auth)) return conformanceSkip("the instance's session.cookieCache is off; run the kit on an instance with session.cookieCache.enabled");
|
|
2662
2834
|
const sdk = await sdkContext(auth);
|
|
@@ -2703,7 +2875,7 @@ function unitPolicyCases(options, known) {
|
|
|
2703
2875
|
["a role computed from app data", (user) => computed.get(user.id) ?? "viewer"]
|
|
2704
2876
|
];
|
|
2705
2877
|
for (const [shape, roleOf] of shapes) {
|
|
2706
|
-
const kitAuth =
|
|
2878
|
+
const kitAuth = await variantOf$1(options, { plugins: [(0, better_auth_plugins.admin)(), (0, better_auth_plugins.customSession)(async ({ user, session }) => ({
|
|
2707
2879
|
user: {
|
|
2708
2880
|
...user,
|
|
2709
2881
|
role: roleOf(user)
|
|
@@ -2757,7 +2929,7 @@ function unitPolicyCases(options, known) {
|
|
|
2757
2929
|
})),
|
|
2758
2930
|
adminCase("Z-admin-null-role", "a NULL or empty stored role answers 403 MISSING_PERMISSION as listUsers does, adminUserIds allows, never a 500", async () => {
|
|
2759
2931
|
const listedId = `conformance-listed-${globalThis.crypto.randomUUID()}`;
|
|
2760
|
-
const kitAuth =
|
|
2932
|
+
const kitAuth = await variantOf$1(options, { plugins: [(0, better_auth_plugins.admin)({ adminUserIds: [listedId] })] });
|
|
2761
2933
|
const nullId = await kitUser(kitAuth, { role: "user" });
|
|
2762
2934
|
await kitUser(kitAuth, {
|
|
2763
2935
|
id: listedId,
|
|
@@ -2813,7 +2985,7 @@ function unitPolicyCases(options, known) {
|
|
|
2813
2985
|
adminCase("Z-admin-dynamic-base-url", "a dynamic baseURL without fallback fails boot (B20); with fallback the admin route is decided", async () => {
|
|
2814
2986
|
const allowedHosts = [new URL(KIT_BASE_URL).host];
|
|
2815
2987
|
const requirement = adminRequirement(admin_, { user: ["list"] });
|
|
2816
|
-
const without = await settle(() => boot(
|
|
2988
|
+
const without = await settle(async () => boot(await variantOf$1(options, {
|
|
2817
2989
|
baseURL: { allowedHosts },
|
|
2818
2990
|
plugins: [(0, better_auth_plugins.admin)()]
|
|
2819
2991
|
}), requirement));
|
|
@@ -2822,7 +2994,7 @@ function unitPolicyCases(options, known) {
|
|
|
2822
2994
|
node_assert_strict.default.fail("a dynamic baseURL without fallback booted");
|
|
2823
2995
|
}
|
|
2824
2996
|
node_assert_strict.default.ok(bootIssueCodes(without.error).includes("DYNAMIC_BASE_URL_WITHOUT_FALLBACK"), String(without.error));
|
|
2825
|
-
const kitAuth =
|
|
2997
|
+
const kitAuth = await variantOf$1(options, {
|
|
2826
2998
|
baseURL: {
|
|
2827
2999
|
allowedHosts,
|
|
2828
3000
|
fallback: KIT_BASE_URL
|
|
@@ -2839,24 +3011,7 @@ function unitPolicyCases(options, known) {
|
|
|
2839
3011
|
}),
|
|
2840
3012
|
conformanceCase("Z-org-ref-types", "fromParam rejects non-string organization inputs without a call; a literal 'active' is an ID; customSession shapes use getActiveMember", async () => {
|
|
2841
3013
|
const leaf = orgLeaf;
|
|
2842
|
-
const
|
|
2843
|
-
const headers = await sessionHeaders(auth, ownerId);
|
|
2844
|
-
const organizationId = await createOrganizationFor(auth, headers);
|
|
2845
|
-
await withRequests(auth, { routes: { org: [require_auth_decorators.Require(withOrganization(leaf, require_organization.fromParam("orgId")))] } }, async ({ request, probe, logger }) => {
|
|
2846
|
-
for (const value of [
|
|
2847
|
-
{ $ne: null },
|
|
2848
|
-
123,
|
|
2849
|
-
["a"],
|
|
2850
|
-
""
|
|
2851
|
-
]) {
|
|
2852
|
-
const from = probe.calls.length;
|
|
2853
|
-
assertDenied(await request("org", headers, { orgId: value }), 403, "ORGANIZATION_REQUIRED", `orgId ${JSON.stringify(value)}`);
|
|
2854
|
-
node_assert_strict.default.deepEqual(betterAuthCalls(probe, from), [], `orgId ${JSON.stringify(value)} reached Better Auth`);
|
|
2855
|
-
}
|
|
2856
|
-
const valid = await request("org", headers, { orgId: organizationId });
|
|
2857
|
-
node_assert_strict.default.equal(valid.ok, true, `the owner's organization ID was denied: ${describeOutcome(valid)}`);
|
|
2858
|
-
node_assert_strict.default.deepEqual(logger.errors(), []);
|
|
2859
|
-
});
|
|
3014
|
+
const { headers, organizationId } = await orgRefInputs(leaf);
|
|
2860
3015
|
await api(auth).setActiveOrganization({
|
|
2861
3016
|
headers,
|
|
2862
3017
|
body: { organizationId }
|
|
@@ -2871,7 +3026,7 @@ function unitPolicyCases(options, known) {
|
|
|
2871
3026
|
node_assert_strict.default.equal(decision.effect, "allow", `the default ref denied the active organization: ${JSON.stringify(decision)}`);
|
|
2872
3027
|
node_assert_strict.default.equal(betterAuthCalls(probe, from).length, 2, `a literal 'active' and the default ref made ${betterAuthCalls(probe, from).length} calls`);
|
|
2873
3028
|
});
|
|
2874
|
-
const shaped =
|
|
3029
|
+
const shaped = await variantOf$1(options, { plugins: [(0, better_auth_plugins.organization)(), (0, better_auth_plugins.customSession)(async ({ user, session }) => ({
|
|
2875
3030
|
user,
|
|
2876
3031
|
session: {
|
|
2877
3032
|
id: session.id,
|
|
@@ -2906,21 +3061,9 @@ function unitPolicyCases(options, known) {
|
|
|
2906
3061
|
});
|
|
2907
3062
|
}, noOrg),
|
|
2908
3063
|
conformanceCase("Z-apikey-quota-per-request", "an API-key session on org policies spends 3 per request, with W_API_KEY_SESSION_MULTIPLIER naming them", async () => {
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
const
|
|
2912
|
-
const organizationId = await createOrganizationFor(auth, headers);
|
|
2913
|
-
const key = await createKey$1(auth, ownerId, { remaining: 100 });
|
|
2914
|
-
if (!await keySessions(auth, key.key)) return conformanceSkip("the instance's apiKey() plugin does not set enableSessionForAPIKeys (or reads no x-api-key header)");
|
|
2915
|
-
const permission = require_organization.orgPermission({ organization: ["update"] }, { organization: require_organization.fromParam("orgId") });
|
|
2916
|
-
const member = require_organization.orgMember({ organization: require_organization.fromParam("orgId") });
|
|
2917
|
-
const routes = { quota: [require_auth_decorators.Require(permission, member)] };
|
|
2918
|
-
await withRequests(auth, { routes }, async ({ request }) => {
|
|
2919
|
-
const before = await remainingOf(auth, key.id);
|
|
2920
|
-
const outcome = await request("quota", { [SDK_KEY_HEADER]: key.key }, { orgId: organizationId });
|
|
2921
|
-
node_assert_strict.default.equal(outcome.ok, true, `the key owner's organization route was denied: ${describeOutcome(outcome)}`);
|
|
2922
|
-
node_assert_strict.default.equal(before - await remainingOf(auth, key.id), 3, "one request with two credential-presenting org policies must spend 3");
|
|
2923
|
-
});
|
|
3064
|
+
const spent = await quotaSpend();
|
|
3065
|
+
if (spent) return spent;
|
|
3066
|
+
const { routes, permission, member } = quotaRoutes();
|
|
2924
3067
|
const advice = async (target, session) => {
|
|
2925
3068
|
const booted = await boot(target, void 0, {
|
|
2926
3069
|
routes,
|
|
@@ -2947,7 +3090,7 @@ function unitPolicyCases(options, known) {
|
|
|
2947
3090
|
for (const id of [permission.policy, member.policy].map((policy) => policy.id)) node_assert_strict.default.ok(multiplier.includes(id), `${label}: W_API_KEY_SESSION_MULTIPLIER does not name ${id}: ${multiplier}`);
|
|
2948
3091
|
for (const text of [full, multiplier]) node_assert_strict.default.equal(text.includes(conditional), !factual, `${label}: ${text}`);
|
|
2949
3092
|
}
|
|
2950
|
-
for (const [label, lines] of [["with session.apiKeySessions: false", await advice(auth, { apiKeySessions: false })], ["without the api-key plugin", await advice(
|
|
3093
|
+
for (const [label, lines] of [["with session.apiKeySessions: false", await advice(auth, { apiKeySessions: false })], ["without the api-key plugin", await advice(await variantOf$1(options, { plugins: [(0, better_auth_plugins.organization)()] }))]]) node_assert_strict.default.deepEqual(lines.filter((text) => text.startsWith("W_API_KEY_")), [], `${label}: API-key session advice was emitted`);
|
|
2951
3094
|
}, noOrg),
|
|
2952
3095
|
conformanceCase("Z-apikey-quota-per-request", "a key resolved by an API-key source on @RequireApiKeyPermission spends 1 per request", async () => {
|
|
2953
3096
|
const leaf = keyLeaf;
|
|
@@ -3066,7 +3209,8 @@ function unitPolicyCases(options, known) {
|
|
|
3066
3209
|
for (const route of ["freshClass", "freshToken"]) assertDenied(await request(route, mocked), 401, void 0, `${route}: a hook-placed session on a freshIdentity route`);
|
|
3067
3210
|
node_assert_strict.default.equal(evaluations.fresh, 0, "a freshIdentity class policy was evaluated for a hook-placed session");
|
|
3068
3211
|
});
|
|
3069
|
-
})
|
|
3212
|
+
}),
|
|
3213
|
+
...(options.deliveries ?? []).flatMap(deliveryCases)
|
|
3070
3214
|
];
|
|
3071
3215
|
}
|
|
3072
3216
|
//#endregion
|
|
@@ -3348,7 +3492,7 @@ function policyConformance(options) {
|
|
|
3348
3492
|
node_assert_strict.default.ok(bootIssueCodes(scoped.error).includes("NON_SINGLETON_EXTENSION"), `boot did not report NON_SINGLETON_EXTENSION (B11): ${String(scoped.error)}`);
|
|
3349
3493
|
}),
|
|
3350
3494
|
conformanceCase("Z-boot-prerequisite", "an instance without the policy's plugin prerequisites fails boot", async () => {
|
|
3351
|
-
const result = await settle(() => boot(
|
|
3495
|
+
const result = await settle(async () => boot(await variantOf$1(options, {}), options.requirement));
|
|
3352
3496
|
if (result.ok) {
|
|
3353
3497
|
await result.value.moduleRef.close();
|
|
3354
3498
|
node_assert_strict.default.fail("an instance without the policy's plugins booted");
|