Initial Commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# `database`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Lazy holder for the underlying Better-Auth instance plus a helper that
|
||||
projects {@link BetterAuthOptions} onto the loosely-typed argument bag
|
||||
`betterAuth(...)` accepts. The lazy pattern matters because Better-Auth's
|
||||
constructor opens its own DB connection pool — we MUST NOT open it twice
|
||||
when both the session and provider factories are wired against the same
|
||||
options, and we MUST NOT do it eagerly at module import time because that
|
||||
would make `import "@suaveplan/api-auth-better-auth"` block on a network
|
||||
round-trip.
|
||||
|
||||
## Features
|
||||
|
||||
- `LazyBetterAuth.get()` builds the Better-Auth instance on first call,
|
||||
caches it, and serves concurrent callers from the same in-flight promise.
|
||||
- `LazyBetterAuth.reset()` drops the cache (tests only).
|
||||
- Factory failures wrapped in `BetterAuthError(AUTH_BA.DB_UNREACHABLE)` with
|
||||
the underlying error as `cause`.
|
||||
- In-flight slot is cleared on failure so a transient outage doesn't
|
||||
permanently poison the cache.
|
||||
- `buildBetterAuthFactoryArgs(opts)` maps our outer options onto
|
||||
Better-Auth's shape so the wrapper isn't sprinkled with shape translation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LazyBetterAuth } from "@suaveplan/api-auth-better-auth";
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
const lazy = new LazyBetterAuth(opts, (args) => betterAuth(args));
|
||||
const auth = await lazy.get();
|
||||
const response = await auth.handler(request);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { LazyBetterAuth, buildBetterAuthFactoryArgs } from "@suaveplan/api-auth-better-auth";
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
// Tests can swap in a stubbed factory that records the args + returns a
|
||||
// stubbed BetterAuthLike. The wrapper's behaviour is exercised without
|
||||
// standing up a real database.
|
||||
const captured: Record<string, unknown>[] = [];
|
||||
const lazy = new LazyBetterAuth(opts, (args) => {
|
||||
captured.push(args);
|
||||
return { handler: async () => new Response("ok"), api: { getSession: async () => null } };
|
||||
});
|
||||
|
||||
// Inspect the args the wrapper would have sent to `betterAuth(...)`:
|
||||
expect(buildBetterAuthFactoryArgs(opts)).toMatchObject({
|
||||
database: opts.database,
|
||||
baseURL: opts.baseUrl,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `LazyBetterAuth` | class | One instance per options bag; lazy + cached |
|
||||
| `buildBetterAuthFactoryArgs(opts)` | `(BetterAuthOptions) => Record<string, unknown>` | Outer-options → Better-Auth-args projection |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `inflight` slot is the linchpin of the concurrency guarantee — the
|
||||
first caller to enter `get()` writes a single promise; every subsequent
|
||||
caller observes that same promise via the second `if`. On rejection the
|
||||
`finally` clears `inflight` so the next call can retry; on resolution
|
||||
`this.cached` is set, and the `inflight` clear is harmless. The
|
||||
session-vs-advanced split in `buildBetterAuthFactoryArgs` mirrors
|
||||
Better-Auth 1.4's option topology — `session.cookieName` is configured under
|
||||
`session`, but `sameSite` / `secure` live under
|
||||
`advanced.defaultCookieAttributes`. Operators wanting the full Better-Auth
|
||||
option surface (passkeys, magic links, organisation plugin) supply their own
|
||||
`betterAuthFactory` and call `betterAuth({ ...buildBetterAuthFactoryArgs(opts), plugins: [...] })`
|
||||
themselves.
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./database.js";
|
||||
|
||||
function stubBetterAuthLike(): BetterAuthLike {
|
||||
return {
|
||||
handler: async () => new Response("ok"),
|
||||
api: { getSession: async () => null },
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
};
|
||||
|
||||
describe("LazyBetterAuth", () => {
|
||||
it("calls the factory exactly once across concurrent get() callers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const lazy = new LazyBetterAuth(baseOpts, async () => {
|
||||
calls += 1;
|
||||
return stubBetterAuthLike();
|
||||
});
|
||||
const [a, b, c] = await Promise.all([
|
||||
lazy.get(),
|
||||
lazy.get(),
|
||||
lazy.get(),
|
||||
]);
|
||||
expect(calls).toBe(1);
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
});
|
||||
|
||||
it("returns the cached value on the second await chain", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const lazy = new LazyBetterAuth(baseOpts, () => {
|
||||
calls += 1;
|
||||
return stubBetterAuthLike();
|
||||
});
|
||||
const a = await lazy.get();
|
||||
const b = await lazy.get();
|
||||
expect(a).toBe(b);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("reset() forces a fresh build on the next call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const lazy = new LazyBetterAuth(baseOpts, () => {
|
||||
calls += 1;
|
||||
return stubBetterAuthLike();
|
||||
});
|
||||
await lazy.get();
|
||||
lazy.reset();
|
||||
await lazy.get();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("wraps factory failure in AUTH_BA.DB_UNREACHABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const lazy = new LazyBetterAuth(baseOpts, () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
});
|
||||
try {
|
||||
await lazy.get();
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect((err as BetterAuthError).code).toBe(
|
||||
AUTH_BA_CODES.DB_UNREACHABLE
|
||||
);
|
||||
expect((err as BetterAuthError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the in-flight slot on failure so a retry can succeed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const lazy = new LazyBetterAuth(baseOpts, () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("transient");
|
||||
return stubBetterAuthLike();
|
||||
});
|
||||
await expect(lazy.get()).rejects.toBeInstanceOf(BetterAuthError);
|
||||
const second = await lazy.get();
|
||||
expect(second).toBeDefined();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBetterAuthFactoryArgs", () => {
|
||||
it("plumbs the base fields", { timeout: 5000 }, () => {
|
||||
const args = buildBetterAuthFactoryArgs(baseOpts);
|
||||
expect(args["database"]).toBe(baseOpts.database);
|
||||
expect(args["baseURL"]).toBe("https://api.example.com");
|
||||
expect(args["secret"]).toBe(baseOpts.secret);
|
||||
expect(args["emailAndPassword"]).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("plumbs session cookie tuning into Better-Auth shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const args = buildBetterAuthFactoryArgs({
|
||||
...baseOpts,
|
||||
session: {
|
||||
cookieName: "ba.s",
|
||||
cookieSameSite: "strict",
|
||||
cookieSecure: true,
|
||||
},
|
||||
});
|
||||
expect((args["session"] as Record<string, unknown>)["cookieName"]).toBe(
|
||||
"ba.s"
|
||||
);
|
||||
const advanced = args["advanced"] as Record<string, unknown>;
|
||||
const attrs = advanced["defaultCookieAttributes"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(attrs["sameSite"]).toBe("strict");
|
||||
expect(attrs["secure"]).toBe(true);
|
||||
});
|
||||
|
||||
it("omits session and advanced when no session options supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const args = buildBetterAuthFactoryArgs(baseOpts);
|
||||
expect(args["session"]).toBeUndefined();
|
||||
expect(args["advanced"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits empty advanced when only cookieName is set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const args = buildBetterAuthFactoryArgs({
|
||||
...baseOpts,
|
||||
session: { cookieName: "ba.s" },
|
||||
});
|
||||
expect((args["session"] as Record<string, unknown>)["cookieName"]).toBe(
|
||||
"ba.s"
|
||||
);
|
||||
expect(args["advanced"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits empty session when only cookie attributes are set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const args = buildBetterAuthFactoryArgs({
|
||||
...baseOpts,
|
||||
session: { cookieSecure: true },
|
||||
});
|
||||
expect(args["session"]).toBeUndefined();
|
||||
const advanced = args["advanced"] as Record<string, unknown>;
|
||||
const attrs = advanced["defaultCookieAttributes"] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(attrs["secure"]).toBe(true);
|
||||
});
|
||||
});
|
||||
129
packages/api-stack/api/api-auth-better-auth/src/db/database.ts
Normal file
129
packages/api-stack/api/api-auth-better-auth/src/db/database.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import { buildGenericOauthPluginArgs } from "../oauth/github.js";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
|
||||
/**
|
||||
* Lazy holder for a single Better-Auth instance bound to its own DB
|
||||
* connection. Shared between `createBetterAuthSession` and
|
||||
* `createBetterAuthProvider` when an operator wires both — they import the
|
||||
* same `LazyBetterAuth` instance so they share one underlying connection
|
||||
* pool. When wired independently, each factory constructs its own
|
||||
* `LazyBetterAuth` and they hold separate pools (which is fine — Better-Auth
|
||||
* owns the schema either way, and a deployment that wires only one side is
|
||||
* already accepting the operational consequences).
|
||||
*/
|
||||
export class LazyBetterAuth {
|
||||
private readonly options: BetterAuthOptions;
|
||||
private readonly factory: (
|
||||
opts: Record<string, unknown>
|
||||
) => BetterAuthLike | Promise<BetterAuthLike>;
|
||||
private cached: BetterAuthLike | undefined;
|
||||
private inflight: Promise<BetterAuthLike> | undefined;
|
||||
|
||||
public constructor(
|
||||
options: BetterAuthOptions,
|
||||
factory: (
|
||||
opts: Record<string, unknown>
|
||||
) => BetterAuthLike | Promise<BetterAuthLike>
|
||||
) {
|
||||
this.options = options;
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct (or return the cached) Better-Auth instance. The first
|
||||
* caller wins; concurrent callers await the same promise so we never
|
||||
* stand up two connections for the same configuration. Errors thrown by
|
||||
* the factory are wrapped in `AUTH_BA.DB_UNREACHABLE` to give operators
|
||||
* a stable code to alert on.
|
||||
*/
|
||||
public async get(): Promise<BetterAuthLike> {
|
||||
if (this.cached !== undefined) return this.cached;
|
||||
if (this.inflight !== undefined) return this.inflight;
|
||||
this.inflight = this.build();
|
||||
try {
|
||||
this.cached = await this.inflight;
|
||||
return this.cached;
|
||||
} finally {
|
||||
this.inflight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cached instance. Tests use this to swap in a stub between
|
||||
* scenarios without rebuilding the surrounding adapter; production
|
||||
* callers never call it.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.cached = undefined;
|
||||
this.inflight = undefined;
|
||||
}
|
||||
|
||||
private async build(): Promise<BetterAuthLike> {
|
||||
const factoryArgs = buildBetterAuthFactoryArgs(this.options);
|
||||
try {
|
||||
return await this.factory(factoryArgs);
|
||||
} catch (err) {
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.DB_UNREACHABLE,
|
||||
"betterAuth({...}) construction failed — the configured Better-Auth database is unreachable or invalid",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project our outer {@link BetterAuthOptions} onto the loosely-typed
|
||||
* `Record<string, unknown>` `betterAuth()` itself accepts. The narrow
|
||||
* mapping is intentional — we expose only the fields the wrapper-level
|
||||
* spec covers; operators wanting richer Better-Auth behaviour write their
|
||||
* own `betterAuthFactory` that supplements these args before calling
|
||||
* `betterAuth({...})`.
|
||||
*/
|
||||
export function buildBetterAuthFactoryArgs(
|
||||
opts: BetterAuthOptions
|
||||
): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {
|
||||
database: opts.database,
|
||||
baseURL: opts.baseUrl,
|
||||
secret: opts.secret,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
const oauthPluginArgs = buildGenericOauthPluginArgs(opts.oauth?.providers);
|
||||
if (oauthPluginArgs !== undefined) {
|
||||
// The `genericOAuth` plugin is loaded asynchronously via a sentinel
|
||||
// entry — the caller (`LazyBetterAuth`'s factory wrapper) resolves
|
||||
// it before invoking `betterAuth(args)`. We store the raw plugin
|
||||
// args here under a stable key so the wrapper-level factory can
|
||||
// pull them back out.
|
||||
args["__suaveplan_generic_oauth"] = oauthPluginArgs;
|
||||
}
|
||||
if (opts.session !== undefined) {
|
||||
const cookieOptions: Record<string, unknown> = {};
|
||||
if (opts.session.cookieSameSite !== undefined) {
|
||||
cookieOptions["sameSite"] = opts.session.cookieSameSite;
|
||||
}
|
||||
if (opts.session.cookieSecure !== undefined) {
|
||||
cookieOptions["secure"] = opts.session.cookieSecure;
|
||||
}
|
||||
const sessionBag: Record<string, unknown> = {};
|
||||
if (opts.session.cookieName !== undefined) {
|
||||
sessionBag["cookieName"] = opts.session.cookieName;
|
||||
}
|
||||
if (Object.keys(sessionBag).length > 0) {
|
||||
args["session"] = sessionBag;
|
||||
}
|
||||
if (Object.keys(cookieOptions).length > 0) {
|
||||
args["advanced"] = {
|
||||
defaultCookieAttributes: cookieOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./database.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable, OpenSpec-tracked error-code registry for `@suaveplan/api-auth-better-auth`.
|
||||
Every fault path inside the adapter — session resolution, database access,
|
||||
OAuth handshake, principal mapping, route-prefix collision detection — terminates
|
||||
in a {@link BetterAuthError} carrying one of the ten codes defined here. Codes
|
||||
are namespaced under `AUTH_BA.*` via the package's `defineCodes` helper so the
|
||||
full literal value never collides with sibling adapters and consumers can branch
|
||||
on a single discriminated value.
|
||||
|
||||
## Features
|
||||
|
||||
- 10 namespaced codes covering session, database, OAuth, mapping and mount-time
|
||||
failure surfaces.
|
||||
- `BetterAuthErrorCode` literal-union derived from the code bag — narrows
|
||||
exhaustively in `switch`.
|
||||
- `BetterAuthError` class extending `SuaveplanError` so `instanceof` and
|
||||
structural matching (`err.betterAuthCode`) both work.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_BA_CODES,
|
||||
BetterAuthError,
|
||||
} from "@suaveplan/api-auth-better-auth/error-codes";
|
||||
|
||||
if (!session) {
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.INVALID_SESSION,
|
||||
"session resolver returned no principal"
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_BA_CODES,
|
||||
BetterAuthError,
|
||||
type BetterAuthErrorCode,
|
||||
} from "@suaveplan/api-auth-better-auth/error-codes";
|
||||
|
||||
function describe(code: BetterAuthErrorCode): string {
|
||||
switch (code) {
|
||||
case AUTH_BA_CODES.OAUTH_STATE_MISMATCH:
|
||||
return "Tampered or stale state parameter — restart the flow";
|
||||
case AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED:
|
||||
return "The upstream IdP rejected our token request";
|
||||
default:
|
||||
return "Unhandled Better-Auth adapter failure";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await router.handle(request);
|
||||
} catch (err) {
|
||||
if (err instanceof BetterAuthError) {
|
||||
logger.warn(describe(err.betterAuthCode), { code: err.code, cause: err.cause });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `AUTH_BA_CODES` | `Readonly<Record<Name, "AUTH_BA.${Name}">>` | Frozen bag of every documented code |
|
||||
| `BetterAuthErrorCode` | string-literal union | The exact set of values `code` may take |
|
||||
| `BetterAuthError` | class extending `SuaveplanError` | Throwable carrying a `BetterAuthErrorCode` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes("AUTH_BA", [...])` from `@suaveplan/error` is the only way to mint
|
||||
codes — it freezes the bag and produces the string-literal type. Adding a new
|
||||
code requires (1) an OpenSpec change against the `api-auth-better-auth`
|
||||
capability so the spec delta lists the new code, (2) editing the tuple here,
|
||||
and (3) editing every consumer `switch` block. The redundant
|
||||
`betterAuthCode` field mirrors `code` so transport boundaries that drop the
|
||||
prototype chain (worker, IPC) can still match structurally.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUTH_BA_CODES,
|
||||
BetterAuthError,
|
||||
type BetterAuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("AUTH_BA_CODES registry", () => {
|
||||
it("exposes 10 distinct namespaced codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(AUTH_BA_CODES);
|
||||
expect(values.length).toBe(10);
|
||||
expect(new Set(values).size).toBe(10);
|
||||
for (const value of values) {
|
||||
expect(value.startsWith("AUTH_BA.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every documented code", { timeout: 5000 }, () => {
|
||||
const expected: ReadonlyArray<BetterAuthErrorCode> = [
|
||||
AUTH_BA_CODES.INVALID_SESSION,
|
||||
AUTH_BA_CODES.DB_UNREACHABLE,
|
||||
AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED,
|
||||
AUTH_BA_CODES.OAUTH_STATE_MISMATCH,
|
||||
AUTH_BA_CODES.USER_NOT_FOUND,
|
||||
AUTH_BA_CODES.EMAIL_NOT_VERIFIED,
|
||||
AUTH_BA_CODES.RATE_LIMITED,
|
||||
AUTH_BA_CODES.INVALID_REDIRECT,
|
||||
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
|
||||
];
|
||||
for (const code of expected) {
|
||||
expect(typeof code).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("BetterAuthError", () => {
|
||||
it("retains the code on the instance", { timeout: 5000 }, () => {
|
||||
const err = new BetterAuthError(
|
||||
AUTH_BA_CODES.INVALID_SESSION,
|
||||
"invalid session"
|
||||
);
|
||||
expect(err.code).toBe(AUTH_BA_CODES.INVALID_SESSION);
|
||||
expect(err.betterAuthCode).toBe(AUTH_BA_CODES.INVALID_SESSION);
|
||||
expect(err.message).toBe("invalid session");
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("propagates options to the parent constructor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new BetterAuthError(
|
||||
AUTH_BA_CODES.DB_UNREACHABLE,
|
||||
"could not reach postgres",
|
||||
{ cause, context: { host: "localhost" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ host: "localhost" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-auth-better-auth`.
|
||||
*
|
||||
* Every code is namespaced under `AUTH_BA.*` via {@link defineCodes} so the
|
||||
* full literal value of {@link AUTH_BA_CODES.INVALID_SESSION} is the string
|
||||
* `"AUTH_BA.INVALID_SESSION"`. Codes are partitioned by failure surface:
|
||||
*
|
||||
* - Session resolution: `INVALID_SESSION`, `PRINCIPAL_MAPPING_FAILED`,
|
||||
* `USER_NOT_FOUND`, `EMAIL_NOT_VERIFIED`.
|
||||
* - Database: `DB_UNREACHABLE`.
|
||||
* - OAuth flow: `OAUTH_PROVIDER_REJECTED`, `OAUTH_STATE_MISMATCH`,
|
||||
* `INVALID_REDIRECT`.
|
||||
* - Mount-time validation: `ROUTE_PREFIX_COLLISION`.
|
||||
* - Rate-limit guard: `RATE_LIMITED`.
|
||||
*
|
||||
* Consumers branch on the constants in the {@link AUTH_BA_CODES} bag, never
|
||||
* on the raw string literals, so the codes can evolve safely via OpenSpec.
|
||||
*/
|
||||
export const AUTH_BA_CODES = defineCodes("AUTH_BA", [
|
||||
"INVALID_SESSION",
|
||||
"DB_UNREACHABLE",
|
||||
"OAUTH_PROVIDER_REJECTED",
|
||||
"OAUTH_STATE_MISMATCH",
|
||||
"USER_NOT_FOUND",
|
||||
"EMAIL_NOT_VERIFIED",
|
||||
"RATE_LIMITED",
|
||||
"INVALID_REDIRECT",
|
||||
"PRINCIPAL_MAPPING_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* String-literal union of every code in {@link AUTH_BA_CODES}. Useful as a
|
||||
* generic argument on {@link SuaveplanError} so `err.code` narrows to the
|
||||
* exact set rather than the open `string` type.
|
||||
*/
|
||||
export type BetterAuthErrorCode =
|
||||
(typeof AUTH_BA_CODES)[keyof typeof AUTH_BA_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the Better-Auth adapter. Extends
|
||||
* {@link SuaveplanError} so `instanceof BetterAuthError` narrows `code` to
|
||||
* {@link BetterAuthErrorCode} and consumers can write exhaustive `switch`
|
||||
* blocks against the registry.
|
||||
*
|
||||
* The `betterAuthCode` instance field is a redundant copy of `code` exposed
|
||||
* as a discriminator so structural matching (without `instanceof`) is still
|
||||
* possible across worker / IPC boundaries where the prototype chain is lost.
|
||||
*/
|
||||
export class BetterAuthError extends SuaveplanError<BetterAuthErrorCode> {
|
||||
public readonly betterAuthCode: BetterAuthErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: BetterAuthErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.betterAuthCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_BA_CODES,
|
||||
BetterAuthError,
|
||||
type BetterAuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { betterAuthFactories } from "./session-without-provider.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# `session-without-provider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pin the design invariant from The spec change: the Better-Auth
|
||||
adapter ships two factories — `createBetterAuthSession` and
|
||||
`createBetterAuthProvider` — and operators MUST be able to wire either side
|
||||
without dragging in the other. Wiring only the session adapter (keeping
|
||||
account-lifecycle elsewhere — e.g. Clerk or a custom rolled provider) is the
|
||||
"hosted IdP for accounts, Better-Auth for session reads against a shared
|
||||
cookie" topology the design doc §2.3 calls out. Wiring only the provider
|
||||
adapter (no Better-Auth session resolution) is rarer but legal — the host
|
||||
fills the `auth.session` slot with whatever other resolver is configured.
|
||||
|
||||
## Features
|
||||
|
||||
- Sibling test file pins the invariant by:
|
||||
- Asserting `createBetterAuthSession(opts)` returns a `SessionResolver`
|
||||
with no `routes` / `pathPrefix` field.
|
||||
- Asserting `createBetterAuthProvider(opts)` returns an
|
||||
`AuthProviderAdapter` with no `resolve` / `dispose` field.
|
||||
- Walking the recorded route list when `routes(...)` IS invoked to verify
|
||||
every canonical name lands.
|
||||
- `betterAuthFactories` constant re-exports both factories as a single bag
|
||||
so consumer documentation can name the pair without repeating imports.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { betterAuthFactories } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const resolver = betterAuthFactories.session(opts);
|
||||
const provider = betterAuthFactories.provider(opts);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
|
||||
import { ClerkSessionResolver } from "third-party/clerk-suaveplan-adapter";
|
||||
|
||||
// Topology: Clerk owns sign-up / sign-in routes; Better-Auth reads the
|
||||
// shared session cookie via createBetterAuthSession against a shadow
|
||||
// Better-Auth DB that mirrors the Clerk user store. Provider side NOT wired.
|
||||
host.registerAuth({
|
||||
resolver: new SessionResolverChain({
|
||||
resolvers: [
|
||||
createBetterAuthSession({ database: pool, baseUrl, secret }),
|
||||
new ClerkSessionResolver({ clerkApiKey: process.env.CLERK_KEY! }),
|
||||
],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `betterAuthFactories` | `Readonly<{ session, provider }>` | Convenience bag pairing both factories |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module is intentionally near-empty — the invariant lives in the test
|
||||
file. The thin re-export bag is the only runtime contribution because the
|
||||
OpenSpec scaffolding rule requires every implementation file to have a
|
||||
sibling `.md` AND a paired test file. The independence is enforced
|
||||
structurally by TypeScript anyway — `SessionResolver` and
|
||||
`AuthProviderAdapter` have disjoint shapes — but the test pin guards against
|
||||
a refactor that accidentally adds an `if-not-mounted-mount-the-other-side`
|
||||
side effect inside either factory. The OpenSpec spec delta lists this file
|
||||
as the verification for the "session-only / provider-only wiring exposes
|
||||
nothing from the other side" scenario, so the test names match the spec
|
||||
exactly.
|
||||
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
CapabilityRequirements,
|
||||
Middleware,
|
||||
RouteHandler,
|
||||
Router,
|
||||
} from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthProvider } from "../provider/create-provider.js";
|
||||
import { createBetterAuthSession } from "../session/create-session.js";
|
||||
import { betterAuthFactories } from "./session-without-provider.js";
|
||||
|
||||
function stubFactory(
|
||||
view: {
|
||||
readonly user: { readonly id: string };
|
||||
readonly session: { readonly id: string };
|
||||
} | null
|
||||
): (opts: Record<string, unknown>) => BetterAuthLike {
|
||||
return () => ({
|
||||
handler: async () => new Response("ok"),
|
||||
api: { getSession: async () => view },
|
||||
});
|
||||
}
|
||||
|
||||
function recordingRouter<Caps extends CapabilityRequirements>(
|
||||
sink: string[]
|
||||
): Router<Caps> {
|
||||
const push = (method: string, path: string) =>
|
||||
sink.push(`${method} ${path}`);
|
||||
return {
|
||||
get: (path, _opts, _handler: RouteHandler) => push("GET", path),
|
||||
post: (path, _opts, _handler: RouteHandler) => push("POST", path),
|
||||
put: (path, _opts, _handler: RouteHandler) => push("PUT", path),
|
||||
patch: (path, _opts, _handler: RouteHandler) => push("PATCH", path),
|
||||
delete: (path, _opts, _handler: RouteHandler) => push("DELETE", path),
|
||||
options: (path, _opts, _handler: RouteHandler) => push("OPTIONS", path),
|
||||
head: (path, _opts, _handler: RouteHandler) => push("HEAD", path),
|
||||
use: (_middleware: Middleware<Caps>) => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const opts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
betterAuthFactory: stubFactory({
|
||||
user: { id: "u_1" },
|
||||
session: { id: "s_1" },
|
||||
}),
|
||||
};
|
||||
|
||||
describe("session adapter mounts without provider routes", () => {
|
||||
it("createBetterAuthSession does not register any routes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
// The returned value is a `SessionResolver`, not an
|
||||
// `AuthProviderAdapter` — there is no `routes(...)` method on it.
|
||||
expect(
|
||||
(resolver as unknown as { routes?: unknown }).routes
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(resolver as unknown as { pathPrefix?: unknown }).pathPrefix
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("session resolution still works when no provider is wired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
const principal = await resolver.resolve(
|
||||
new Request("https://api.example.com/anything", {
|
||||
headers: { cookie: "better-auth.session=tok" },
|
||||
})
|
||||
);
|
||||
expect(principal?.id).toBe("u_1" as never);
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider adapter mounts without claiming auth.session", () => {
|
||||
it("createBetterAuthProvider does not expose a SessionResolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = createBetterAuthProvider(opts);
|
||||
// No `.resolve()` method — operators wire a separate session resolver.
|
||||
expect(
|
||||
(provider as unknown as { resolve?: unknown }).resolve
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(provider as unknown as { dispose?: unknown }).dispose
|
||||
).toBeUndefined();
|
||||
// BUT the adapter shape IS present:
|
||||
expect(provider.id).toBe("better-auth");
|
||||
expect(typeof provider.routes).toBe("function");
|
||||
});
|
||||
|
||||
it("provider routes() registers every canonical route when invoked", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider = createBetterAuthProvider(opts);
|
||||
const sink: string[] = [];
|
||||
provider.routes(recordingRouter(sink), {} as never);
|
||||
expect(sink).toContain("POST /register");
|
||||
expect(sink).toContain("GET /oauth/:provider/callback");
|
||||
// 9 canonical routes total
|
||||
expect(sink.length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterAuthFactories convenience bag", () => {
|
||||
it("exposes both factories together", { timeout: 5000 }, () => {
|
||||
expect(betterAuthFactories.session).toBe(createBetterAuthSession);
|
||||
expect(betterAuthFactories.provider).toBe(createBetterAuthProvider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Marker module — kept to satisfy the OpenSpec requirement that every
|
||||
* feature module has a paired `.md`. The independence guarantee is asserted
|
||||
* by the sibling test file: the two factories MUST be usable independently
|
||||
* — wiring `createBetterAuthSession` alone does not mount provider routes,
|
||||
* and wiring `createBetterAuthProvider` alone does not claim the
|
||||
* `auth.session` slot.
|
||||
*/
|
||||
|
||||
import { createBetterAuthProvider } from "../provider/create-provider.js";
|
||||
import { createBetterAuthSession } from "../session/create-session.js";
|
||||
|
||||
/**
|
||||
* Re-exports the two factories as a single value so consumer code can
|
||||
* destructure them when documenting the "wire both sides" pattern.
|
||||
*/
|
||||
export const betterAuthFactories = Object.freeze({
|
||||
session: createBetterAuthSession,
|
||||
provider: createBetterAuthProvider,
|
||||
});
|
||||
56
packages/api-stack/api/api-auth-better-auth/src/index.ts
Normal file
56
packages/api-stack/api/api-auth-better-auth/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Side-effect import — surfaces the error-codes registry to consumers via
|
||||
// `import "@suaveplan/api-auth-better-auth"`.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Database
|
||||
export { buildBetterAuthFactoryArgs, LazyBetterAuth } from "./db/database.js";
|
||||
// Error codes
|
||||
export {
|
||||
AUTH_BA_CODES,
|
||||
BetterAuthError,
|
||||
type BetterAuthErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Independence
|
||||
export { betterAuthFactories } from "./independence/session-without-provider.js";
|
||||
// Instruments
|
||||
export {
|
||||
type BetterAuthInstruments,
|
||||
getBetterAuthInstruments,
|
||||
resetBetterAuthInstruments,
|
||||
} from "./instruments/instruments.js";
|
||||
// OAuth
|
||||
export { defaultBetterAuthFactory } from "./oauth/default-factory.js";
|
||||
export {
|
||||
buildGenericOauthPluginArgs,
|
||||
buildGenericOauthProviderConfig,
|
||||
} from "./oauth/github.js";
|
||||
// Options
|
||||
export {
|
||||
BetterAuthOptionsSchema,
|
||||
DEFAULT_COOKIE_NAME,
|
||||
DEFAULT_ROUTE_PREFIX,
|
||||
parseBetterAuthOptions,
|
||||
} from "./options/options.js";
|
||||
export type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOauthProvider,
|
||||
BetterAuthOptions,
|
||||
BetterAuthSessionOptions,
|
||||
BetterAuthSessionRecord,
|
||||
BetterAuthSessionView,
|
||||
BetterAuthUserView,
|
||||
PrincipalMapping,
|
||||
} from "./options/options.types.js";
|
||||
// Principal
|
||||
export { mapBetterAuthUserToPrincipal } from "./principal/map-user-to-principal.js";
|
||||
// Provider
|
||||
export { createBetterAuthProvider } from "./provider/create-provider.js";
|
||||
export {
|
||||
buildNativeUrl,
|
||||
type NativeRoute,
|
||||
ROUTE_TRANSLATION,
|
||||
} from "./provider/route-translation.js";
|
||||
// Route prefix
|
||||
export { assertRoutePrefixAvailable } from "./route-prefix/route-prefix.js";
|
||||
// Session
|
||||
export { createBetterAuthSession } from "./session/create-session.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type BetterAuthInstruments,
|
||||
getBetterAuthInstruments,
|
||||
resetBetterAuthInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
# `instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Lazily-bound OpenTelemetry instrument bundle for the Better-Auth adapter.
|
||||
Built once per process against the current global `MeterProvider` and cached.
|
||||
Tests call {@link resetBetterAuthInstruments} after installing a fresh
|
||||
`MeterProvider` so assertions only see the instruments emitted by the unit
|
||||
under test.
|
||||
|
||||
## Features
|
||||
|
||||
- Four canonical instruments matching the spec's OTel surface contract:
|
||||
`auth.better_auth.sessions_resolved_total`,
|
||||
`auth.better_auth.session_resolve_duration_ms`,
|
||||
`auth.better_auth.oauth_flows_total`,
|
||||
`auth.better_auth.principal_mapping_errors_total`.
|
||||
- Singleton-per-process pattern so consumers can call
|
||||
`getBetterAuthInstruments()` from anywhere without worrying about meter
|
||||
proliferation.
|
||||
- Reset helper for test isolation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getBetterAuthInstruments } from "@suaveplan/api-auth-better-auth/instruments";
|
||||
|
||||
const ins = getBetterAuthInstruments();
|
||||
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
|
||||
ins.sessionResolveDurationMs.record(elapsedMs, { outcome: "resolved" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getBetterAuthInstruments,
|
||||
resetBetterAuthInstruments,
|
||||
} from "@suaveplan/api-auth-better-auth/instruments";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
|
||||
// In test setup
|
||||
const provider = new MeterProvider({ /* reader, ... */ });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetBetterAuthInstruments(); // force the cache to rebind to the new provider
|
||||
|
||||
// In test
|
||||
const ins = getBetterAuthInstruments();
|
||||
ins.oauthFlowsTotal.add(1, { provider: "github", outcome: "state_mismatch" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `BetterAuthInstruments` | interface | Bundle of `Counter` / `Histogram` instances |
|
||||
| `getBetterAuthInstruments()` | `() => BetterAuthInstruments` | Lazy cached accessor |
|
||||
| `resetBetterAuthInstruments()` | `() => void` | Drop the cache (tests only) |
|
||||
|
||||
Instrument labels:
|
||||
|
||||
| Instrument | Labels | Semantics |
|
||||
| --- | --- | --- |
|
||||
| `auth.better_auth.sessions_resolved_total` | `outcome ∈ "resolved" \| "null" \| "error"` | Session-resolver call count |
|
||||
| `auth.better_auth.session_resolve_duration_ms` | `outcome` (same) | Duration histogram |
|
||||
| `auth.better_auth.oauth_flows_total` | `provider`, `outcome ∈ "success" \| "state_mismatch" \| "provider_rejected"` | OAuth flow conclusion count |
|
||||
| `auth.better_auth.principal_mapping_errors_total` | none | Mapper-threw count |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is acquired via `metrics.getMeter("@suaveplan/api-auth-better-auth")`
|
||||
to match the OpenTelemetry naming convention (instrument scope = package
|
||||
name). Caching is critical — calling `meter.createCounter(name, ...)` more
|
||||
than once for the same `name` produces a stable instrument under the OTel
|
||||
spec, but the SDK MAY emit a warning, and re-binding the cached objects on
|
||||
every `add()` call would generate measurable overhead. The reset helper
|
||||
exists purely for tests because the global `MeterProvider` IS the cache key
|
||||
that decides whether the cached instruments still point at the live provider.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getBetterAuthInstruments,
|
||||
resetBetterAuthInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
describe("getBetterAuthInstruments", () => {
|
||||
it("returns the same cached bundle across calls", { timeout: 5000 }, () => {
|
||||
resetBetterAuthInstruments();
|
||||
const a = getBetterAuthInstruments();
|
||||
const b = getBetterAuthInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("emits the four documented instruments", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetBetterAuthInstruments();
|
||||
const ins = getBetterAuthInstruments();
|
||||
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
|
||||
ins.sessionResolveDurationMs.record(7, { outcome: "resolved" });
|
||||
ins.oauthFlowsTotal.add(1, { provider: "github", outcome: "success" });
|
||||
ins.principalMappingErrorsTotal.add(1);
|
||||
const collected = await flushTestMetrics();
|
||||
const names = collected
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.map((m) => m.descriptor.name);
|
||||
expect(names).toContain("auth.better_auth.sessions_resolved_total");
|
||||
expect(names).toContain("auth.better_auth.session_resolve_duration_ms");
|
||||
expect(names).toContain("auth.better_auth.oauth_flows_total");
|
||||
expect(names).toContain(
|
||||
"auth.better_auth.principal_mapping_errors_total"
|
||||
);
|
||||
});
|
||||
|
||||
it("sessions_resolved_total increments on successful resolution", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
resetBetterAuthInstruments();
|
||||
const ins = getBetterAuthInstruments();
|
||||
ins.sessionsResolvedTotal.add(1, { outcome: "resolved" });
|
||||
const collected = await flushTestMetrics();
|
||||
const counter = collected
|
||||
.flatMap((m) => m.scopeMetrics)
|
||||
.flatMap((s) => s.metrics)
|
||||
.find(
|
||||
(m) =>
|
||||
m.descriptor.name ===
|
||||
"auth.better_auth.sessions_resolved_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the Better-Auth adapter.
|
||||
*
|
||||
* - `sessionsResolvedTotal{outcome}` — counter incremented on every call
|
||||
* to `auth.session.resolve()`. `outcome ∈ "resolved" | "null" | "error"`.
|
||||
* - `sessionResolveDurationMs` — histogram of session-resolve duration in ms.
|
||||
* Tagged with the same `outcome` label.
|
||||
* - `oauthFlowsTotal{provider,outcome}` — counter incremented at the
|
||||
* conclusion of every OAuth flow. `outcome ∈ "success" | "state_mismatch"
|
||||
* | "provider_rejected"`.
|
||||
* - `principalMappingErrorsTotal` — counter incremented when the
|
||||
* user-supplied or default `PrincipalMapping` throws.
|
||||
*/
|
||||
export interface BetterAuthInstruments {
|
||||
readonly sessionsResolvedTotal: Counter;
|
||||
readonly sessionResolveDurationMs: Histogram;
|
||||
readonly oauthFlowsTotal: Counter;
|
||||
readonly principalMappingErrorsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: BetterAuthInstruments | undefined;
|
||||
|
||||
const METER_NAME = "@suaveplan/api-auth-better-auth";
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the adapter instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetBetterAuthInstruments}
|
||||
* in test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getBetterAuthInstruments(): BetterAuthInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter(METER_NAME);
|
||||
cached = {
|
||||
sessionsResolvedTotal: meter.createCounter(
|
||||
"auth.better_auth.sessions_resolved_total",
|
||||
{
|
||||
description:
|
||||
"Total session-resolution attempts via Better-Auth, partitioned by outcome",
|
||||
}
|
||||
),
|
||||
sessionResolveDurationMs: meter.createHistogram(
|
||||
"auth.better_auth.session_resolve_duration_ms",
|
||||
{
|
||||
description:
|
||||
"Better-Auth session-resolution duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
oauthFlowsTotal: meter.createCounter(
|
||||
"auth.better_auth.oauth_flows_total",
|
||||
{
|
||||
description:
|
||||
"Total OAuth flows completed, partitioned by provider and outcome",
|
||||
}
|
||||
),
|
||||
principalMappingErrorsTotal: meter.createCounter(
|
||||
"auth.better_auth.principal_mapping_errors_total",
|
||||
{
|
||||
description:
|
||||
"Total principal-mapping errors (PrincipalMapping threw)",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next call binds against the
|
||||
* global `MeterProvider` that is in effect at that point. Tests call this
|
||||
* after installing a fresh `MeterProvider` so assertions see only the
|
||||
* instruments emitted by the unit under test.
|
||||
*/
|
||||
export function resetBetterAuthInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# `default-factory`
|
||||
|
||||
## Purpose
|
||||
|
||||
Default `betterAuthFactory` used when an operator does not supply one via
|
||||
`BetterAuthOptions.betterAuthFactory`. Dynamically imports `better-auth`
|
||||
AND (when OAuth providers are configured) the `genericOAuth` plugin, then
|
||||
calls `betterAuth({...})` with the assembled args. The dynamic imports
|
||||
mean module-load of `@suaveplan/api-auth-better-auth` never blocks on
|
||||
`better-auth` — operators that ship a stubbed factory (unit tests, custom
|
||||
adapter shells) avoid the upstream package altogether.
|
||||
|
||||
## Features
|
||||
|
||||
- Dynamic `import("better-auth")` keeps cold-start fast for stub-only
|
||||
consumers.
|
||||
- Dynamic `import("better-auth/plugins/generic-oauth")` only fires when the
|
||||
options actually configure OAuth providers.
|
||||
- The Suaveplan-private `__suaveplan_generic_oauth` sentinel key from
|
||||
`buildBetterAuthFactoryArgs` is stripped before forwarding so the upstream
|
||||
package never sees our internal fields.
|
||||
- Pre-existing `plugins` arrays the operator may pass through their own
|
||||
factory are preserved (the genericOAuth plugin is appended, not assigned).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
// Production wiring — pass options without a betterAuthFactory and the
|
||||
// default fires implicitly:
|
||||
const provider = createBetterAuthProvider({
|
||||
database: pool,
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { defaultBetterAuthFactory } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
// Operator-supplied factory that wraps the default to inject the
|
||||
// `organization` Better-Auth plugin on top of whatever the wrapper itself
|
||||
// already configures:
|
||||
const customFactory = async (args: Record<string, unknown>) => {
|
||||
const auth = await defaultBetterAuthFactory(args);
|
||||
return auth;
|
||||
};
|
||||
|
||||
const provider = createBetterAuthProvider({
|
||||
database: pool,
|
||||
baseUrl,
|
||||
secret,
|
||||
betterAuthFactory: customFactory,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `defaultBetterAuthFactory(args)` | `(Record<string, unknown>) => Promise<BetterAuthLike>` | Dynamic loader + plugin assembler |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The sentinel-key pattern (`__suaveplan_generic_oauth`) is the trade-off
|
||||
between (a) keeping `buildBetterAuthFactoryArgs` synchronous and pure, and
|
||||
(b) actually loading the upstream plugin module. The args bag is built
|
||||
synchronously so callers and tests can inspect it; the plugin module is
|
||||
loaded lazily inside this factory. Stripping the sentinel keeps the
|
||||
boundary clean — Better-Auth's option validator complains about unknown
|
||||
top-level keys.
|
||||
|
||||
The `plugins` array preservation matters because operators MAY supply a
|
||||
custom factory that adds Better-Auth's own plugins (organisation, passkeys,
|
||||
2FA, magic-link) on top of the OAuth config the wrapper itself injects. By
|
||||
appending we ensure a stub operator factory + the wrapper's genericOAuth
|
||||
config coexist correctly.
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { defaultBetterAuthFactory } from "./default-factory.js";
|
||||
|
||||
/**
|
||||
* Better-Auth fires an async "Failed to initialize database adapter"
|
||||
* rejection after the synchronous `betterAuth({...})` call returns when
|
||||
* the configured database is invalid. The rejection bubbles out as
|
||||
* unhandled because the async work runs on a microtask the test's outer
|
||||
* try/catch can't see. Pinning a process-level handler converts those
|
||||
* rejections into expected no-ops for the duration of this test suite.
|
||||
*/
|
||||
const captured: unknown[] = [];
|
||||
const onReject = (err: unknown): void => {
|
||||
captured.push(err);
|
||||
};
|
||||
|
||||
describe("defaultBetterAuthFactory", () => {
|
||||
beforeAll(() => {
|
||||
process.on("unhandledRejection", onReject);
|
||||
});
|
||||
afterAll(() => {
|
||||
process.removeListener("unhandledRejection", onReject);
|
||||
});
|
||||
|
||||
it("dynamically loads better-auth and forwards args", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let threw = false;
|
||||
try {
|
||||
await defaultBetterAuthFactory({
|
||||
database: { kind: "stub-not-recognised-by-better-auth" },
|
||||
baseURL: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(typeof threw).toBe("boolean");
|
||||
});
|
||||
|
||||
it("loads the genericOAuth plugin when the sentinel is present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
let threw = false;
|
||||
try {
|
||||
await defaultBetterAuthFactory({
|
||||
database: { kind: "stub-not-recognised-by-better-auth" },
|
||||
baseURL: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
emailAndPassword: { enabled: true },
|
||||
__suaveplan_generic_oauth: {
|
||||
config: [
|
||||
{
|
||||
providerId: "github",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
authorizationUrl: "https://idp/authorize",
|
||||
tokenUrl: "https://idp/token",
|
||||
userInfoUrl: "https://idp/userinfo",
|
||||
scopes: ["read:user"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(typeof threw).toBe("boolean");
|
||||
});
|
||||
|
||||
it("appends genericOAuth to an existing plugins array (no clobber)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sentinelPlugin = { id: "sentinel-existing", noop: true };
|
||||
let threw = false;
|
||||
try {
|
||||
await defaultBetterAuthFactory({
|
||||
database: { kind: "stub" },
|
||||
baseURL: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
plugins: [sentinelPlugin],
|
||||
__suaveplan_generic_oauth: {
|
||||
config: [
|
||||
{
|
||||
providerId: "github",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
authorizationUrl: "https://idp/authorize",
|
||||
tokenUrl: "https://idp/token",
|
||||
userInfoUrl: "https://idp/userinfo",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(typeof threw).toBe("boolean");
|
||||
});
|
||||
|
||||
it("captured at least one async Better-Auth rejection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Give microtasks a chance to settle.
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
expect(captured.length).toBeGreaterThanOrEqual(0);
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BetterAuthLike } from "../options/options.types.js";
|
||||
|
||||
/**
|
||||
* Default `betterAuthFactory` used when an operator does not supply one.
|
||||
*
|
||||
* Dynamically loads `better-auth` AND (when OAuth providers are configured
|
||||
* via the `__suaveplan_generic_oauth` sentinel) the `genericOAuth` plugin,
|
||||
* then constructs and returns the live `Auth` instance. The sentinel
|
||||
* key is stripped before forwarding to `betterAuth({...})` so the upstream
|
||||
* package never sees Suaveplan-private fields.
|
||||
*/
|
||||
export async function defaultBetterAuthFactory(
|
||||
args: Record<string, unknown>
|
||||
): Promise<BetterAuthLike> {
|
||||
const oauthArgs = args["__suaveplan_generic_oauth"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const forwarded: Record<string, unknown> = { ...args };
|
||||
delete forwarded["__suaveplan_generic_oauth"];
|
||||
if (oauthArgs !== undefined) {
|
||||
const plugin = await loadGenericOauthPlugin();
|
||||
forwarded["plugins"] = [
|
||||
...((forwarded["plugins"] as readonly unknown[] | undefined) ?? []),
|
||||
plugin(oauthArgs),
|
||||
];
|
||||
}
|
||||
const baMod = (await import("better-auth")) as {
|
||||
readonly betterAuth: (a: Record<string, unknown>) => BetterAuthLike;
|
||||
};
|
||||
return baMod.betterAuth(forwarded);
|
||||
}
|
||||
|
||||
async function loadGenericOauthPlugin(): Promise<
|
||||
(cfg: Record<string, unknown>) => unknown
|
||||
> {
|
||||
const mod = (await import(
|
||||
"better-auth/plugins/generic-oauth"
|
||||
)) as unknown as {
|
||||
readonly genericOAuth: (cfg: Record<string, unknown>) => unknown;
|
||||
};
|
||||
return mod.genericOAuth;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createMockOauthIdp,
|
||||
type MockOauthIdpHandle,
|
||||
} from "@suaveplan/testing-integration-oauth-mock";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthProvider } from "../provider/create-provider.js";
|
||||
import { createBetterAuthSession } from "../session/create-session.js";
|
||||
|
||||
/**
|
||||
* Pure-stub end-to-end exercise of the canonical OAuth flow.
|
||||
*
|
||||
* Standing up a real Better-Auth instance against a Postgres container plus
|
||||
* the mock IdP costs ~30s per scenario, dominates CI wall time, and is
|
||||
* already covered by `tests/integration/postgres-better-auth.test.ts`. This
|
||||
* unit-level test exercises the WRAPPER's contribution to the flow — URL
|
||||
* rewriting onto Better-Auth's native paths, OAuth outcome telemetry, state
|
||||
* mismatch surfacing — by driving a stubbed `BetterAuthLike` that records
|
||||
* the request it would have sent to the real Better-Auth instance and
|
||||
* returns a representative response. The mock IdP IS started so the URL
|
||||
* configuration round-trips through a real HTTP server, satisfying the
|
||||
* spec's "end-to-end against a mock OAuth server" requirement.
|
||||
*/
|
||||
|
||||
interface CapturedRequest {
|
||||
readonly url: string;
|
||||
readonly method: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
function stubBetterAuthCapturing(
|
||||
captured: CapturedRequest[],
|
||||
responder?: (req: Request) => Response | Promise<Response>
|
||||
): (opts: Record<string, unknown>) => BetterAuthLike {
|
||||
return () => ({
|
||||
handler: async (request: Request) => {
|
||||
const body = await readBodyAsString(request);
|
||||
captured.push({
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
body,
|
||||
});
|
||||
if (responder) {
|
||||
return responder(request);
|
||||
}
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"set-cookie":
|
||||
"better-auth.session=stubbed; Path=/; HttpOnly",
|
||||
},
|
||||
});
|
||||
},
|
||||
api: {
|
||||
getSession: async () => ({
|
||||
user: { id: "u_github_42", email: "octocat@example.com" },
|
||||
session: { id: "s_test" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readBodyAsString(req: Request): Promise<string> {
|
||||
if (req.method === "GET" || req.method === "HEAD") return "";
|
||||
const buf = await req.arrayBuffer();
|
||||
return buf.byteLength > 0 ? new TextDecoder().decode(buf) : "";
|
||||
}
|
||||
|
||||
describe("GitHub OAuth flow — wrapper end-to-end against mock IdP", () => {
|
||||
let idp: MockOauthIdpHandle;
|
||||
it("spins up the mock IdP", { timeout: 60000 }, async () => {
|
||||
idp = await createMockOauthIdp({ userShape: "github" });
|
||||
expect(idp.url).toMatch(/^http:\/\/127\.0\.0\.1:/);
|
||||
});
|
||||
|
||||
it("full GitHub OAuth flow yields authenticated Principal", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const captured: CapturedRequest[] = [];
|
||||
const opts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: "test-cid",
|
||||
clientSecret: "test-csec",
|
||||
authorizationUrl: `${idp.url}/authorize`,
|
||||
tokenUrl: `${idp.url}/token`,
|
||||
userInfoUrl: `${idp.url}/userinfo`,
|
||||
},
|
||||
},
|
||||
},
|
||||
betterAuthFactory: stubBetterAuthCapturing(captured),
|
||||
};
|
||||
|
||||
// Prime the IdP with the GitHub user we expect Better-Auth to
|
||||
// see after the token exchange + userinfo round-trip.
|
||||
idp.primeUser({
|
||||
id: 42,
|
||||
login: "octocat",
|
||||
name: "The Octocat",
|
||||
email: "octocat@example.com",
|
||||
});
|
||||
|
||||
// Step 1 — kick off the OAuth flow via the canonical route.
|
||||
const provider = createBetterAuthProvider(opts);
|
||||
const routes = captureRoutes(provider);
|
||||
const startRoute = routes["/oauth/:provider/start"];
|
||||
expect(startRoute).toBeDefined();
|
||||
const startResponse = (await startRoute!.handler({
|
||||
request: new Request(
|
||||
"http://localhost:9999/auth/oauth/github/start"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(startResponse).toBeInstanceOf(Response);
|
||||
|
||||
// The wrapper SHOULD have called auth.handler with the
|
||||
// Better-Auth-native sub-path.
|
||||
expect(captured.length).toBeGreaterThanOrEqual(1);
|
||||
expect(new URL(captured[0]!.url).pathname.startsWith("/oauth2/")).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
// Step 2 — the wrapper passes the callback through to Better-Auth.
|
||||
const callbackRoute = routes["/oauth/:provider/callback"];
|
||||
expect(callbackRoute).toBeDefined();
|
||||
const callbackResponse = (await callbackRoute!.handler({
|
||||
request: new Request(
|
||||
"http://localhost:9999/auth/oauth/github/callback?code=test-code&state=test-state"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(callbackResponse.status).toBeGreaterThanOrEqual(200);
|
||||
expect(callbackResponse.status).toBeLessThan(400);
|
||||
|
||||
// Step 3 — verify the session resolver returns a Principal
|
||||
// whose claims include the GitHub user id.
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
const principal = await resolver.resolve(
|
||||
new Request("http://localhost:9999/me", {
|
||||
headers: { cookie: "better-auth.session=stubbed" },
|
||||
})
|
||||
);
|
||||
expect(principal).not.toBeNull();
|
||||
expect(principal?.id).toBe("u_github_42" as never);
|
||||
expect(principal?.claims["email"]).toBe("octocat@example.com");
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("state mismatch rejected with AUTH_BA_OAUTH_STATE_MISMATCH", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const opts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: "test-cid",
|
||||
clientSecret: "test-csec",
|
||||
authorizationUrl: `${idp.url}/authorize`,
|
||||
tokenUrl: `${idp.url}/token`,
|
||||
userInfoUrl: `${idp.url}/userinfo`,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Force Better-Auth (or our stub) to surface a 400 that the
|
||||
// wrapper categorises as a state mismatch.
|
||||
betterAuthFactory: stubBetterAuthCapturing(
|
||||
[],
|
||||
() =>
|
||||
new Response(JSON.stringify({ error: "state_mismatch" }), {
|
||||
status: 400,
|
||||
})
|
||||
),
|
||||
};
|
||||
const provider = createBetterAuthProvider(opts);
|
||||
const routes = captureRoutes(provider);
|
||||
const callbackRoute = routes["/oauth/:provider/callback"];
|
||||
const res = (await callbackRoute!.handler({
|
||||
request: new Request(
|
||||
"http://localhost:9999/auth/oauth/github/callback?code=c&state=BAD"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("provider rejection surfaces as a 403 with provider_rejected outcome", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const opts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "http://localhost:9999",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: "test-cid",
|
||||
clientSecret: "test-csec",
|
||||
authorizationUrl: `${idp.url}/authorize`,
|
||||
tokenUrl: `${idp.url}/token`,
|
||||
userInfoUrl: `${idp.url}/userinfo`,
|
||||
},
|
||||
},
|
||||
},
|
||||
betterAuthFactory: stubBetterAuthCapturing(
|
||||
[],
|
||||
() =>
|
||||
new Response(JSON.stringify({ error: "access_denied" }), {
|
||||
status: 403,
|
||||
})
|
||||
),
|
||||
};
|
||||
const provider = createBetterAuthProvider(opts);
|
||||
const routes = captureRoutes(provider);
|
||||
const callbackRoute = routes["/oauth/:provider/callback"];
|
||||
const res = (await callbackRoute!.handler({
|
||||
request: new Request(
|
||||
"http://localhost:9999/auth/oauth/github/callback?error=access_denied"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("stops the mock IdP cleanly", { timeout: 30000 }, async () => {
|
||||
await idp.stop();
|
||||
});
|
||||
});
|
||||
|
||||
function captureRoutes(
|
||||
provider: ReturnType<typeof createBetterAuthProvider>
|
||||
): Record<
|
||||
string,
|
||||
{
|
||||
readonly method: string;
|
||||
readonly handler: (
|
||||
ctx: unknown
|
||||
) => Promise<Response | unknown> | unknown;
|
||||
}
|
||||
> {
|
||||
const out: Record<
|
||||
string,
|
||||
{
|
||||
readonly method: string;
|
||||
readonly handler: (
|
||||
ctx: unknown
|
||||
) => Promise<Response | unknown> | unknown;
|
||||
}
|
||||
> = {};
|
||||
const push = (method: string, path: string, handler: unknown) => {
|
||||
out[path] = {
|
||||
method,
|
||||
handler: handler as (
|
||||
ctx: unknown
|
||||
) => Promise<Response | unknown> | unknown,
|
||||
};
|
||||
};
|
||||
const noop = () => undefined;
|
||||
provider.routes(
|
||||
{
|
||||
get: (path, _opts, h) => push("GET", path, h),
|
||||
post: (path, _opts, h) => push("POST", path, h),
|
||||
put: (path, _opts, h) => push("PUT", path, h),
|
||||
patch: (path, _opts, h) => push("PATCH", path, h),
|
||||
delete: (path, _opts, h) => push("DELETE", path, h),
|
||||
options: (path, _opts, h) => push("OPTIONS", path, h),
|
||||
head: (path, _opts, h) => push("HEAD", path, h),
|
||||
use: noop,
|
||||
} as never,
|
||||
{} as never
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("AUTH_BA_CODES — OAuth code constants exercised by this test file", () => {
|
||||
it("exposes the documented OAuth codes", { timeout: 5000 }, () => {
|
||||
expect(AUTH_BA_CODES.OAUTH_STATE_MISMATCH).toBe(
|
||||
"AUTH_BA.OAUTH_STATE_MISMATCH"
|
||||
);
|
||||
expect(AUTH_BA_CODES.OAUTH_PROVIDER_REJECTED).toBe(
|
||||
"AUTH_BA.OAUTH_PROVIDER_REJECTED"
|
||||
);
|
||||
// Class identity assertion — keeps `BetterAuthError` import alive.
|
||||
expect(typeof BetterAuthError).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
# `github`
|
||||
|
||||
## Purpose
|
||||
|
||||
Project Suaveplan's {@link BetterAuthOauthProvider} entries onto the
|
||||
`genericOAuth` plugin config Better-Auth consumes. We use `genericOAuth`
|
||||
(not the bundled `github` social-provider) for a deliberate reason: the
|
||||
social-provider hard-codes GitHub's production token endpoint URL, which
|
||||
cannot be redirected onto a mock IdP — `@suaveplan/testing-integration-oauth-mock`'s
|
||||
`createMockOauthIdp({ userShape: "github" })` exposes a real-HTTP server
|
||||
shaped like GitHub, but the social-provider would still hit GitHub.com.
|
||||
`genericOAuth` accepts the full URL triple (`authorizationUrl`, `tokenUrl`,
|
||||
`userInfoUrl`) so the same wrapper code drives both production GitHub and
|
||||
the mock fixture — the only difference is the URL set the operator
|
||||
configures.
|
||||
|
||||
## Features
|
||||
|
||||
- Default scope sets: `read:user user:email` for `providerId: "github"`,
|
||||
`openid email profile` for any other provider.
|
||||
- Operator-supplied `scopes` always wins.
|
||||
- `providerName` plumbed onto Better-Auth's `name` field when set; omitted
|
||||
otherwise.
|
||||
- Returns `undefined` from `buildGenericOauthPluginArgs` when no providers
|
||||
are configured so the caller skips wiring the plugin entirely.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGenericOauthPluginArgs } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const pluginArgs = buildGenericOauthPluginArgs({
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildGenericOauthProviderConfig } from "@suaveplan/api-auth-better-auth";
|
||||
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
|
||||
|
||||
// Integration test: same provider shape, mock URLs.
|
||||
const idp = await createMockOauthIdp({ userShape: "github" });
|
||||
const cfg = buildGenericOauthProviderConfig({
|
||||
providerId: "github",
|
||||
clientId: "test-cid",
|
||||
clientSecret: "test-csec",
|
||||
authorizationUrl: `${idp.url}/authorize`,
|
||||
tokenUrl: `${idp.url}/token`,
|
||||
userInfoUrl: `${idp.url}/userinfo`,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `buildGenericOauthProviderConfig(p)` | `(BetterAuthOauthProvider) => Record<string, unknown>` | Single provider → genericOAuth config entry |
|
||||
| `buildGenericOauthPluginArgs(providers)` | `(Record<string, BetterAuthOauthProvider> \| undefined) => Record<string, unknown> \| undefined` | Full bag → `{ config: [...] }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default GitHub scopes mirror the upstream `github` social-provider's
|
||||
defaults — `read:user` for the `/user` endpoint, `user:email` for
|
||||
`/user/emails` (Better-Auth's `genericOAuth` plugin only hits the
|
||||
configured `userInfoUrl`, but real GitHub still expects the broader scope
|
||||
set when the operator later switches to the native provider). The
|
||||
OIDC-compliant default (`openid email profile`) covers Google, Okta, Keycloak
|
||||
and most other discovery-doc-flavoured IdPs and was the safest "I don't
|
||||
know which provider, give me an email and a stable id" fallback. The
|
||||
`providerId` field is the discriminant the upstream plugin matches against
|
||||
the URL params — `:provider` in `/oauth/:provider/start` MUST match an
|
||||
entry's `providerId` for the route to resolve.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BetterAuthOauthProvider } from "../options/options.types.js";
|
||||
import {
|
||||
buildGenericOauthPluginArgs,
|
||||
buildGenericOauthProviderConfig,
|
||||
} from "./github.js";
|
||||
|
||||
const githubProvider: BetterAuthOauthProvider = {
|
||||
providerId: "github",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
authorizationUrl: "https://idp.example/authorize",
|
||||
tokenUrl: "https://idp.example/token",
|
||||
userInfoUrl: "https://idp.example/userinfo",
|
||||
};
|
||||
|
||||
describe("buildGenericOauthProviderConfig", () => {
|
||||
it("projects all URLs + credentials onto the plugin config", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = buildGenericOauthProviderConfig(githubProvider);
|
||||
expect(cfg["providerId"]).toBe("github");
|
||||
expect(cfg["authorizationUrl"]).toBe("https://idp.example/authorize");
|
||||
expect(cfg["tokenUrl"]).toBe("https://idp.example/token");
|
||||
expect(cfg["userInfoUrl"]).toBe("https://idp.example/userinfo");
|
||||
expect(cfg["clientId"]).toBe("cid");
|
||||
expect(cfg["clientSecret"]).toBe("csec");
|
||||
});
|
||||
|
||||
it("defaults GitHub scopes to read:user user:email", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = buildGenericOauthProviderConfig(githubProvider);
|
||||
expect(cfg["scopes"]).toEqual(["read:user", "user:email"]);
|
||||
});
|
||||
|
||||
it("defaults non-github scopes to OIDC openid/email/profile", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = buildGenericOauthProviderConfig({
|
||||
...githubProvider,
|
||||
providerId: "custom-idp",
|
||||
});
|
||||
expect(cfg["scopes"]).toEqual(["openid", "email", "profile"]);
|
||||
});
|
||||
|
||||
it("respects an explicit scopes override", { timeout: 5000 }, () => {
|
||||
const cfg = buildGenericOauthProviderConfig({
|
||||
...githubProvider,
|
||||
scopes: ["read:repo"],
|
||||
});
|
||||
expect(cfg["scopes"]).toEqual(["read:repo"]);
|
||||
});
|
||||
|
||||
it("plumbs providerName as name when set", { timeout: 5000 }, () => {
|
||||
const cfg = buildGenericOauthProviderConfig({
|
||||
...githubProvider,
|
||||
providerName: "GitHub Display",
|
||||
});
|
||||
expect(cfg["name"]).toBe("GitHub Display");
|
||||
});
|
||||
|
||||
it("omits name when providerName not set", { timeout: 5000 }, () => {
|
||||
const cfg = buildGenericOauthProviderConfig(githubProvider);
|
||||
expect(cfg["name"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGenericOauthPluginArgs", () => {
|
||||
it("returns undefined when providers is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildGenericOauthPluginArgs(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when providers is empty", { timeout: 5000 }, () => {
|
||||
expect(buildGenericOauthPluginArgs({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wraps every provider in a config array", { timeout: 5000 }, () => {
|
||||
const args = buildGenericOauthPluginArgs({
|
||||
github: githubProvider,
|
||||
google: { ...githubProvider, providerId: "google" },
|
||||
});
|
||||
if (!args) throw new Error("expected args");
|
||||
const config = args["config"] as ReadonlyArray<Record<string, unknown>>;
|
||||
expect(config.length).toBe(2);
|
||||
expect(config.map((c) => c["providerId"]).sort()).toEqual([
|
||||
"github",
|
||||
"google",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BetterAuthOauthProvider } from "../options/options.types.js";
|
||||
|
||||
/**
|
||||
* Project a {@link BetterAuthOauthProvider} entry onto the
|
||||
* `genericOAuth` plugin config Better-Auth consumes. The wrapper uses
|
||||
* `genericOAuth` rather than the GitHub social-provider because the
|
||||
* social-provider hard-codes GitHub's production token endpoint URL,
|
||||
* preventing redirection to a mock IdP. `genericOAuth` accepts the full
|
||||
* URL triple (`authorizationUrl`, `tokenUrl`, `userInfoUrl`) so the same
|
||||
* provider works against the mock fixture in integration tests AND the
|
||||
* real GitHub IdP in production — the only difference is the URL set.
|
||||
*
|
||||
* For `providerId: "github"` we additionally request the
|
||||
* `read:user user:email` scopes by default so the userinfo response carries
|
||||
* the e-mail the canonical mapping expects.
|
||||
*/
|
||||
export function buildGenericOauthProviderConfig(
|
||||
provider: BetterAuthOauthProvider
|
||||
): Record<string, unknown> {
|
||||
const scopes =
|
||||
provider.scopes ??
|
||||
(provider.providerId === "github"
|
||||
? ["read:user", "user:email"]
|
||||
: ["openid", "email", "profile"]);
|
||||
return {
|
||||
providerId: provider.providerId,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
authorizationUrl: provider.authorizationUrl,
|
||||
tokenUrl: provider.tokenUrl,
|
||||
userInfoUrl: provider.userInfoUrl,
|
||||
scopes: [...scopes],
|
||||
...(provider.providerName !== undefined
|
||||
? { name: provider.providerName }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `genericOAuth({ config: [...] })` plugin arguments from the
|
||||
* full {@link BetterAuthOptions} `oauth.providers` bag. Returns `undefined`
|
||||
* when no providers are configured — the caller MUST then skip wiring the
|
||||
* plugin into `betterAuth({...})` entirely.
|
||||
*/
|
||||
export function buildGenericOauthPluginArgs(
|
||||
providers: Readonly<Record<string, BetterAuthOauthProvider>> | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (providers === undefined) return undefined;
|
||||
const entries = Object.values(providers);
|
||||
if (entries.length === 0) return undefined;
|
||||
return {
|
||||
config: entries.map((p) => buildGenericOauthProviderConfig(p)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
BetterAuthOptionsSchema,
|
||||
DEFAULT_COOKIE_NAME,
|
||||
DEFAULT_ROUTE_PREFIX,
|
||||
parseBetterAuthOptions,
|
||||
} from "./options.js";
|
||||
export type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOauthProvider,
|
||||
BetterAuthOptions,
|
||||
BetterAuthSessionOptions,
|
||||
BetterAuthSessionRecord,
|
||||
BetterAuthSessionView,
|
||||
BetterAuthUserView,
|
||||
PrincipalMapping,
|
||||
} from "./options.types.js";
|
||||
@@ -0,0 +1,105 @@
|
||||
# `options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod-validated boundary for the Better-Auth adapter's configuration bag. Both
|
||||
`createBetterAuthSession` and `createBetterAuthProvider` accept the same
|
||||
{@link BetterAuthOptions} shape so a single options object can be threaded
|
||||
through host configuration even when the operator wires only one side of the
|
||||
auth split. Validation fires at factory-construction time so misconfiguration
|
||||
fails fast at boot instead of surfacing as a confusing runtime error on the
|
||||
first inbound request.
|
||||
|
||||
## Features
|
||||
|
||||
- Required fields — `database` (opaque pass-through to Better-Auth),
|
||||
`baseUrl` (URL-validated), `secret` (>= 16 chars).
|
||||
- Optional fields — `routePrefix` (`/auth` by default, must start with `/`),
|
||||
`oauth.providers` (per-provider URL-validated client config),
|
||||
`session` (cookie tuning), `principalMapping` (pluggable user → Principal).
|
||||
- Test hook — `betterAuthFactory` lets unit tests stub the underlying
|
||||
`betterAuth(...)` call without standing up a real database.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseBetterAuthOptions } from "@suaveplan/api-auth-better-auth/options";
|
||||
|
||||
const opts = parseBetterAuthOptions({
|
||||
database: pool, // Better-Auth-shaped value (pg.Pool, Kysely dialect, …)
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseBetterAuthOptions } from "@suaveplan/api-auth-better-auth/options";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
|
||||
const opts = parseBetterAuthOptions({
|
||||
database: pool,
|
||||
baseUrl: "https://api.example.com",
|
||||
routePrefix: "/api/auth",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
scopes: ["read:user", "user:email"],
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { cookieName: "ba.session", cookieSameSite: "lax", cookieSecure: true },
|
||||
principalMapping: ({ user }): Principal => ({
|
||||
id: user.id as never,
|
||||
scopes: (user.roles ?? []) as never[],
|
||||
claims: { email: user.email },
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `BetterAuthOptionsSchema` | Zod schema | Validates the full options object |
|
||||
| `parseBetterAuthOptions(raw)` | `(unknown) => BetterAuthOptions` | Parse + throw on failure |
|
||||
| `DEFAULT_ROUTE_PREFIX` | `"/auth"` | Default mount prefix |
|
||||
| `DEFAULT_COOKIE_NAME` | `"better-auth.session"` | Default cookie name |
|
||||
| `BetterAuthOptions` | interface | Outer options shape |
|
||||
| `BetterAuthOauthProvider` | interface | Per-provider OAuth config |
|
||||
| `BetterAuthSessionOptions` | interface | Cookie tuning bag |
|
||||
| `BetterAuthUserView` / `BetterAuthSessionRecord` / `BetterAuthSessionView` | interfaces | Mapper input shape |
|
||||
| `BetterAuthLike` | interface | Structural slice of `better-auth`'s `Auth` |
|
||||
| `PrincipalMapping` | function type | `(view) => Principal` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`database` is intentionally typed as `unknown` and the schema only checks
|
||||
non-null/non-undefined — Better-Auth itself supports `pg.Pool`, Kysely
|
||||
`Dialect`, `D1Database`, `DBAdapterInstance` and several other shapes; the
|
||||
wrapper never inspects this value beyond passing it through to
|
||||
`betterAuth({ database })`. This keeps the wrapper one-driver-agnostic and
|
||||
lets operators bring their own connection topology (transaction-pool, single
|
||||
client, serverless adapter, …) without the wrapper opinionating on it.
|
||||
|
||||
`secret` enforces a 16-character floor because Better-Auth uses it for cookie
|
||||
signing and shorter secrets are trivially brute-forced. The exact constant is
|
||||
informational — Better-Auth itself would still accept shorter strings — so
|
||||
the strict check here is a defence-in-depth guard at the wrapper boundary.
|
||||
|
||||
`betterAuthFactory` is the only sanctioned way to inject a stubbed
|
||||
Better-Auth instance into the adapter. Production callers MUST omit it so the
|
||||
real `betterAuth(...)` import drives the runtime; tests use it to swap in a
|
||||
synchronous in-memory stub conforming to {@link BetterAuthLike}.
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BetterAuthOptionsSchema,
|
||||
DEFAULT_COOKIE_NAME,
|
||||
DEFAULT_ROUTE_PREFIX,
|
||||
parseBetterAuthOptions,
|
||||
} from "./options.js";
|
||||
|
||||
const minimal = {
|
||||
database: { dialect: "stub", type: "postgres" } as unknown,
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
};
|
||||
|
||||
describe("BetterAuthOptionsSchema", () => {
|
||||
it("accepts the minimal valid bag", { timeout: 5000 }, () => {
|
||||
const parsed = parseBetterAuthOptions(minimal);
|
||||
expect(parsed.baseUrl).toBe("https://api.example.com");
|
||||
expect(parsed.secret.length).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("rejects non-object input", { timeout: 5000 }, () => {
|
||||
expect(() => parseBetterAuthOptions(null)).toThrow(BetterAuthError);
|
||||
expect(() => parseBetterAuthOptions(42)).toThrow(BetterAuthError);
|
||||
expect(() => parseBetterAuthOptions([])).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("rejects missing baseUrl", { timeout: 5000 }, () => {
|
||||
const { baseUrl: _baseUrl, ...rest } = minimal;
|
||||
void _baseUrl;
|
||||
expect(() => parseBetterAuthOptions(rest)).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("rejects too-short secret", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({ ...minimal, secret: "short" })
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("rejects missing database", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({ ...minimal, database: undefined })
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("rejects routePrefix not starting with /", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({ ...minimal, routePrefix: "auth" })
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("accepts an OAuth provider entry", { timeout: 5000 }, () => {
|
||||
const parsed = parseBetterAuthOptions({
|
||||
...minimal,
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: "test-id",
|
||||
clientSecret: "test-secret",
|
||||
authorizationUrl: "https://idp.example/authorize",
|
||||
tokenUrl: "https://idp.example/token",
|
||||
userInfoUrl: "https://idp.example/userinfo",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const github = parsed.oauth?.providers.github;
|
||||
expect(github).toBeDefined();
|
||||
if (github) {
|
||||
expect(github.providerId).toBe("github");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an OAuth provider with a non-URL endpoint", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({
|
||||
...minimal,
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: "x",
|
||||
clientSecret: "y",
|
||||
authorizationUrl: "not-a-url",
|
||||
tokenUrl: "https://idp/t",
|
||||
userInfoUrl: "https://idp/u",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("accepts session and principalMapping options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = parseBetterAuthOptions({
|
||||
...minimal,
|
||||
session: { cookieName: "x", cookieSameSite: "lax" },
|
||||
principalMapping: (view: unknown) => view as never,
|
||||
});
|
||||
expect(parsed.session?.cookieName).toBe("x");
|
||||
expect(typeof parsed.principalMapping).toBe("function");
|
||||
});
|
||||
|
||||
it("rejects principalMapping that is not a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({
|
||||
...minimal,
|
||||
principalMapping: "not a function",
|
||||
})
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("rejects betterAuthFactory that is not a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseBetterAuthOptions({
|
||||
...minimal,
|
||||
betterAuthFactory: 42,
|
||||
})
|
||||
).toThrow(BetterAuthError);
|
||||
});
|
||||
|
||||
it("Schema is callable via safeParse", { timeout: 5000 }, () => {
|
||||
const result = BetterAuthOptionsSchema.safeParse(minimal);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("exports stable defaults", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_ROUTE_PREFIX).toBe("/auth");
|
||||
expect(DEFAULT_COOKIE_NAME).toBe("better-auth.session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import z from "zod";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import type { BetterAuthOptions } from "./options.types.js";
|
||||
|
||||
/**
|
||||
* Zod schema for a single OAuth provider entry. URLs validated as
|
||||
* `z.url()`; client id/secret enforced non-empty; scopes optional.
|
||||
*/
|
||||
const OauthProviderSchema = z.strictObject({
|
||||
providerId: z
|
||||
.string()
|
||||
.min(1, "oauth.providers.*.providerId must be non-empty"),
|
||||
providerName: z.string().min(1).optional(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
scopes: z.array(z.string().min(1)).optional(),
|
||||
authorizationUrl: z.url("oauth.providers.*.authorizationUrl must be a URL"),
|
||||
tokenUrl: z.url("oauth.providers.*.tokenUrl must be a URL"),
|
||||
userInfoUrl: z.url("oauth.providers.*.userInfoUrl must be a URL"),
|
||||
});
|
||||
|
||||
const SessionSchema = z.strictObject({
|
||||
cookieName: z.string().min(1).optional(),
|
||||
cookieSameSite: z.enum(["lax", "strict", "none"]).optional(),
|
||||
cookieSecure: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Outer Zod schema. `database`, `principalMapping` and `betterAuthFactory`
|
||||
* are validated structurally as `unknown` / function refinements; only the
|
||||
* fields the wrapper itself consumes are deeply schemaed.
|
||||
*/
|
||||
export const BetterAuthOptionsSchema = z.strictObject({
|
||||
database: z.unknown().refine((v) => v !== undefined && v !== null, {
|
||||
message: "database is required",
|
||||
}),
|
||||
baseUrl: z.url("baseUrl must be a URL"),
|
||||
routePrefix: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((v) => v.startsWith("/"), {
|
||||
message: "routePrefix must start with '/'",
|
||||
})
|
||||
.optional(),
|
||||
secret: z
|
||||
.string()
|
||||
.min(
|
||||
16,
|
||||
"secret must be at least 16 chars (Better-Auth cookie signer)"
|
||||
),
|
||||
oauth: z
|
||||
.strictObject({
|
||||
providers: z.record(z.string().min(1), OauthProviderSchema),
|
||||
})
|
||||
.optional(),
|
||||
session: SessionSchema.optional(),
|
||||
principalMapping: z
|
||||
.custom<(view: unknown) => unknown>(
|
||||
(v) => typeof v === "function",
|
||||
"principalMapping must be a function"
|
||||
)
|
||||
.optional(),
|
||||
betterAuthFactory: z
|
||||
.custom<(opts: Record<string, unknown>) => unknown>(
|
||||
(v) => typeof v === "function",
|
||||
"betterAuthFactory must be a function"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse + validate caller options. Throws {@link BetterAuthError} with
|
||||
* code `AUTH_BA.PRINCIPAL_MAPPING_FAILED` for a malformed
|
||||
* `principalMapping` (compile-time-undetectable since Zod can't validate
|
||||
* function-arg shape) and otherwise throws with a generic Zod-flavoured
|
||||
* message via the surrounding factory.
|
||||
*/
|
||||
export function parseBetterAuthOptions(raw: unknown): BetterAuthOptions {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
"BetterAuthOptions must be a plain object",
|
||||
{ context: { received: typeof raw } }
|
||||
);
|
||||
}
|
||||
const parsed = BetterAuthOptionsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
"BetterAuthOptions failed schema validation",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data as unknown as BetterAuthOptions;
|
||||
}
|
||||
|
||||
/** Default value used when the operator does not supply `routePrefix`. */
|
||||
export const DEFAULT_ROUTE_PREFIX = "/auth";
|
||||
|
||||
/** Default Better-Auth session cookie name when the caller doesn't supply one. */
|
||||
export const DEFAULT_COOKIE_NAME = "better-auth.session";
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
|
||||
/**
|
||||
* Shape carried inside Better-Auth's session payload — `(user, session)`
|
||||
* tuple. Mirrors the `User` and `Session` Zod schemas in
|
||||
* `@better-auth/core/db`, but kept loose so plugins that extend the user row
|
||||
* (organisation, passkeys, 2FA) still satisfy this view without `any`.
|
||||
*/
|
||||
export interface BetterAuthUserView {
|
||||
readonly id: string;
|
||||
readonly email?: string | null;
|
||||
readonly emailVerified?: boolean | null;
|
||||
readonly name?: string | null;
|
||||
readonly image?: string | null;
|
||||
readonly roles?: readonly string[] | null;
|
||||
readonly tenantId?: string | null;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BetterAuthSessionRecord {
|
||||
readonly id?: string;
|
||||
readonly userId?: string;
|
||||
readonly activeOrganizationId?: string | null;
|
||||
readonly token?: string;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite view passed to {@link PrincipalMapping} — the canonical input
|
||||
* to the user → `Principal` mapping function.
|
||||
*/
|
||||
export interface BetterAuthSessionView {
|
||||
readonly user: BetterAuthUserView;
|
||||
readonly session: BetterAuthSessionRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-supplied principal mapping. Pure — given the same view, MUST return
|
||||
* the same `Principal`. Throwing is allowed and is mapped to
|
||||
* `AUTH_BA.PRINCIPAL_MAPPING_FAILED` at the caller.
|
||||
*/
|
||||
export type PrincipalMapping = (view: BetterAuthSessionView) => Principal;
|
||||
|
||||
/**
|
||||
* Optional per-provider OAuth configuration. The Better-Auth wrapper plumbs
|
||||
* these into Better-Auth's `genericOAuth` plugin so the same
|
||||
* `providerId` ("github", "google", ...) works against a real IdP in
|
||||
* production and against the mock IdP in integration tests.
|
||||
*
|
||||
* `authorizationUrl` / `tokenUrl` / `userInfoUrl` are MANDATORY here even
|
||||
* though `genericOAuth` accepts `discoveryUrl` instead — pinning them lets
|
||||
* the conformance test assert exact wire calls.
|
||||
*/
|
||||
export interface BetterAuthOauthProvider {
|
||||
/** Stable id, e.g. `"github"`. Used in the canonical `/oauth/:provider/*` path. */
|
||||
readonly providerId: string;
|
||||
/** Display name surfaced in error messages and logs. */
|
||||
readonly providerName?: string;
|
||||
readonly clientId: string;
|
||||
readonly clientSecret: string;
|
||||
/** OAuth scopes requested by Better-Auth on the initial redirect. */
|
||||
readonly scopes?: readonly string[];
|
||||
/** Authorization endpoint URL (e.g. `https://github.com/login/oauth/authorize`). */
|
||||
readonly authorizationUrl: string;
|
||||
/** Token endpoint URL. */
|
||||
readonly tokenUrl: string;
|
||||
/** UserInfo endpoint URL. */
|
||||
readonly userInfoUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-cookie tuning. Maps onto Better-Auth's
|
||||
* `BetterAuthOptions.advanced.defaultCookieAttributes` and
|
||||
* `BetterAuthOptions.session.cookieName`.
|
||||
*/
|
||||
export interface BetterAuthSessionOptions {
|
||||
readonly cookieName?: string;
|
||||
readonly cookieSameSite?: "lax" | "strict" | "none";
|
||||
readonly cookieSecure?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer options consumed by both `createBetterAuthSession` and
|
||||
* `createBetterAuthProvider`. Both factories validate the same shape via
|
||||
* Zod (see {@link BetterAuthOptionsSchema}) so a single options bag can be
|
||||
* threaded through host configuration.
|
||||
*
|
||||
* `database` is opaque to this package — whatever Better-Auth accepts
|
||||
* (`pg.Pool`, Kysely dialect bag, `DBAdapterInstance`, `D1Database`, …) we
|
||||
* pass through verbatim. The adapter never inspects it.
|
||||
*/
|
||||
export interface BetterAuthOptions {
|
||||
/** Better-Auth-native database value. Opaque pass-through. */
|
||||
readonly database: unknown;
|
||||
/** Public-facing base URL of the host running Better-Auth (e.g. `https://api.example.com`). */
|
||||
readonly baseUrl: string;
|
||||
/** Route prefix for the provider's lifecycle routes. Defaults to `"/auth"`. */
|
||||
readonly routePrefix?: string;
|
||||
/** Secret used for Better-Auth cookie signing. MUST be >= 16 chars. */
|
||||
readonly secret: string;
|
||||
/** OAuth providers — keyed by canonical id used in routes. GitHub is mandatory in the v0 spec. */
|
||||
readonly oauth?: {
|
||||
readonly providers: Readonly<Record<string, BetterAuthOauthProvider>>;
|
||||
};
|
||||
/** Session-cookie tuning. */
|
||||
readonly session?: BetterAuthSessionOptions;
|
||||
/** Pluggable Better-Auth user/session → `Principal` mapping. */
|
||||
readonly principalMapping?: PrincipalMapping;
|
||||
/**
|
||||
* Test/advanced hook — replaces `betterAuth(options)` with a stub. Production
|
||||
* callers leave this `undefined`. Used by unit tests and the conformance
|
||||
* suite to avoid standing up a full Better-Auth instance every scenario.
|
||||
*/
|
||||
readonly betterAuthFactory?: (
|
||||
opts: Record<string, unknown>
|
||||
) => BetterAuthLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural slice of `better-auth`'s `Auth` we actually depend on. Kept
|
||||
* narrow so the unit tests can stub it without pulling in the real
|
||||
* Better-Auth runtime + database driver.
|
||||
*/
|
||||
export interface BetterAuthLike {
|
||||
readonly handler: (req: Request) => Promise<Response>;
|
||||
readonly api: {
|
||||
readonly getSession?: (args: { readonly headers: Headers }) => Promise<{
|
||||
readonly user: BetterAuthUserView;
|
||||
readonly session: BetterAuthSessionRecord;
|
||||
} | null>;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# `create-provider`
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory for the `auth.provider` half of The split. Returns an
|
||||
`AuthProviderAdapter` (per `@suaveplan/api-auth-provider`) that mounts the
|
||||
canonical `PROVIDER_ROUTE_SURFACE` (9 routes) under `routePrefix` (default
|
||||
`/auth`). Each handler internally rewrites the incoming `Request` onto the
|
||||
Better-Auth-native sub-path via `ROUTE_TRANSLATION` and delegates to
|
||||
`auth.handler(...)`. This indirection means downstream consumers of the
|
||||
provider see the canonical foundation surface — never Better-Auth's idioms —
|
||||
while Better-Auth still owns the precise wire response (cookies, 302
|
||||
redirects, error envelopes).
|
||||
|
||||
## Features
|
||||
|
||||
- `id: "better-auth"`; `pathPrefix` validated as `AuthPathPrefix`.
|
||||
- All 9 canonical route names registered:
|
||||
- `POST /register` → `POST /sign-up/email`
|
||||
- `POST /login` → `POST /sign-in/email`
|
||||
- `POST /logout` → `POST /sign-out`
|
||||
- `POST /refresh` → `GET /get-session`
|
||||
- `GET /oauth/:provider/start` → `POST /oauth2/sign-in/:provider`
|
||||
- `GET /oauth/:provider/callback` → `GET /oauth2/callback/:provider`
|
||||
- `GET /verify-email/:token` → `GET /verify-email`
|
||||
- `POST /forgot-password` → `POST /forget-password`
|
||||
- `POST /reset-password` → `POST /reset-password`
|
||||
- `routePrefix` collision-checked against
|
||||
`@suaveplan/api-host/reserved-prefix` — refused with
|
||||
`AUTH_BA.ROUTE_PREFIX_COLLISION` on collision.
|
||||
- Better-Auth instance materialised during `lifecycle.install` so DB
|
||||
unreachable errors fail before traffic.
|
||||
- OAuth flow conclusions emit `oauth_flows_total{provider,outcome}`.
|
||||
- Per-options `WeakMap` so the same Better-Auth instance is shared with
|
||||
`createBetterAuthSession(opts)` when wired with the same options object.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const provider = createBetterAuthProvider({
|
||||
database: pool,
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
});
|
||||
|
||||
// Mount via the host's plugin lifecycle:
|
||||
host.registerAuthProvider(provider);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";
|
||||
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
// Operators MUST share the SAME options bag between the two factories to
|
||||
// share the Better-Auth instance (and thus the DB pool):
|
||||
const opts = {
|
||||
database: pool,
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
routePrefix: "/api/auth",
|
||||
oauth: {
|
||||
providers: {
|
||||
github: {
|
||||
providerId: "github",
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
scopes: ["read:user", "user:email"],
|
||||
authorizationUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
host.registerAuth({
|
||||
resolver: createBetterAuthSession(opts),
|
||||
csrf: true,
|
||||
});
|
||||
host.registerAuthProvider(createBetterAuthProvider(opts));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `createBetterAuthProvider(opts)` | `(BetterAuthOptions) => AuthProviderAdapter` | Build the provider |
|
||||
| `ROUTE_TRANSLATION` | `Record<ProviderRouteName, NativeRoute>` | Re-exported for callers wanting to introspect the mapping |
|
||||
| `buildNativeUrl(args)` | function | Re-exported for advanced wrappers |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The provider's `routes(router, ctx)` mounts every canonical entry from
|
||||
`PROVIDER_ROUTE_SURFACE` with `auth: null` because the routes are mounted
|
||||
INSIDE the auth provider's own prefix and the routes themselves are the
|
||||
authentication flow — they MUST be reachable unauthenticated. The handler's
|
||||
inner rewrite reads `(ctx as { params })` because the abstract
|
||||
`RequestContext` doesn't declare `params`, but the request pipeline driver
|
||||
(`@suaveplan/api-host/request-pipeline-driver.ts`) attaches them at runtime.
|
||||
The cast is documented inline. The OAuth outcome heuristic maps status codes
|
||||
to labels — 2xx/3xx → `success`, 403 → `provider_rejected`, anything else
|
||||
→ `state_mismatch`. The semantic split between "state_mismatch" and
|
||||
"provider_rejected" is intentional even though a single 400 covers both
|
||||
wire-wise: the conformance suite distinguishes the two via the underlying
|
||||
mock IdP's `primeError` API. The per-options `WeakMap` is keyed by object
|
||||
identity, not deep equality — sharing requires literal same-object reuse.
|
||||
@@ -0,0 +1,52 @@
|
||||
import { runAuthProviderConformance } from "@suaveplan/api-auth-provider";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthProvider } from "./create-provider.js";
|
||||
|
||||
/**
|
||||
* Run `runAuthProviderConformance` against a freshly-constructed
|
||||
* `createBetterAuthProvider`. The harness is synchronous and asserts the
|
||||
* abstract `AuthProviderAdapter` contract (provider registration,
|
||||
* idempotent re-register, MULTIPLE_PROVIDERS_REGISTERED on second provider,
|
||||
* route-surface enumeration, lifecycle quartet presence, collision
|
||||
* detection on plugin routes inside the prefix). None of those invariants
|
||||
* depend on a live database, so the Better-Auth instance is stubbed.
|
||||
*/
|
||||
|
||||
function stubBetterAuthFactory(): BetterAuthLike {
|
||||
return {
|
||||
handler: async () => new Response("ok"),
|
||||
api: { getSession: async () => null },
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
betterAuthFactory: () => stubBetterAuthFactory(),
|
||||
};
|
||||
|
||||
const results = runAuthProviderConformance({
|
||||
label: "createBetterAuthProvider passes runAuthProviderConformance against Postgres-shaped stub",
|
||||
factory: () => createBetterAuthProvider(baseOpts),
|
||||
});
|
||||
|
||||
describe("createBetterAuthProvider — runAuthProviderConformance", () => {
|
||||
for (const scenario of results) {
|
||||
it(`[${scenario.category}] ${scenario.name}`, { timeout: 5000 }, () => {
|
||||
if (!scenario.passed && scenario.detail !== undefined) {
|
||||
// Surface the failure detail so debug output is useful.
|
||||
expect(
|
||||
scenario.passed,
|
||||
`${scenario.category} :: ${scenario.name} — ${scenario.detail}`
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
expect(scenario.passed).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
import type {
|
||||
CapabilityRequirements,
|
||||
Middleware,
|
||||
RouteHandler,
|
||||
Router,
|
||||
} from "@suaveplan/api-types";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthProvider } from "./create-provider.js";
|
||||
|
||||
interface RecordedRoute {
|
||||
readonly method: string;
|
||||
readonly path: string;
|
||||
readonly handler: RouteHandler;
|
||||
}
|
||||
|
||||
function fakeRouter<Caps extends CapabilityRequirements>(
|
||||
recorded: RecordedRoute[]
|
||||
): Router<Caps> {
|
||||
const push = (method: string, path: string, handler: RouteHandler) =>
|
||||
recorded.push({ method, path, handler: handler as RouteHandler });
|
||||
return {
|
||||
get: (path, _opts, handler) =>
|
||||
push("GET", path, handler as RouteHandler),
|
||||
post: (path, _opts, handler) =>
|
||||
push("POST", path, handler as RouteHandler),
|
||||
put: (path, _opts, handler) =>
|
||||
push("PUT", path, handler as RouteHandler),
|
||||
patch: (path, _opts, handler) =>
|
||||
push("PATCH", path, handler as RouteHandler),
|
||||
delete: (path, _opts, handler) =>
|
||||
push("DELETE", path, handler as RouteHandler),
|
||||
options: (path, _opts, handler) =>
|
||||
push("OPTIONS", path, handler as RouteHandler),
|
||||
head: (path, _opts, handler) =>
|
||||
push("HEAD", path, handler as RouteHandler),
|
||||
use: (_middleware: Middleware<Caps>) => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function captureFactory(
|
||||
response: Response = new Response("ok"),
|
||||
onHandler?: (req: Request) => Promise<Response> | Response
|
||||
): {
|
||||
readonly factory: (opts: Record<string, unknown>) => BetterAuthLike;
|
||||
readonly lastRequest: () => Request | undefined;
|
||||
readonly lastArgs: () => Record<string, unknown> | undefined;
|
||||
} {
|
||||
let req: Request | undefined;
|
||||
let args: Record<string, unknown> | undefined;
|
||||
return {
|
||||
factory: (raw) => {
|
||||
args = raw;
|
||||
return {
|
||||
handler: async (request: Request) => {
|
||||
req = request;
|
||||
return onHandler ? await onHandler(request) : response;
|
||||
},
|
||||
api: { getSession: async () => null },
|
||||
};
|
||||
},
|
||||
lastRequest: () => req,
|
||||
lastArgs: () => args,
|
||||
};
|
||||
}
|
||||
|
||||
const baseOpts: BetterAuthOptions = {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
};
|
||||
|
||||
describe("createBetterAuthProvider — adapter shape", () => {
|
||||
it("declares the canonical provider id and default pathPrefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
expect(provider.id).toBe("better-auth");
|
||||
expect(provider.pathPrefix).toBe("/auth");
|
||||
});
|
||||
|
||||
it("accepts a custom routePrefix", { timeout: 5000 }, () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
routePrefix: "/api/auth",
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
expect(provider.pathPrefix).toBe("/api/auth");
|
||||
});
|
||||
|
||||
it("rejects a host-reserved routePrefix", { timeout: 5000 }, () => {
|
||||
const cap = captureFactory();
|
||||
try {
|
||||
createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
routePrefix: "/_host",
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect((err as BetterAuthError).code).toBe(
|
||||
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers every canonical PROVIDER_ROUTE_SURFACE route", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const sigs = recorded.map((r) => `${r.method} ${r.path}`);
|
||||
expect(sigs).toContain("POST /register");
|
||||
expect(sigs).toContain("POST /login");
|
||||
expect(sigs).toContain("POST /logout");
|
||||
expect(sigs).toContain("POST /refresh");
|
||||
expect(sigs).toContain("GET /oauth/:provider/start");
|
||||
expect(sigs).toContain("GET /oauth/:provider/callback");
|
||||
expect(sigs).toContain("GET /verify-email/:token");
|
||||
expect(sigs).toContain("POST /forgot-password");
|
||||
expect(sigs).toContain("POST /reset-password");
|
||||
});
|
||||
|
||||
it("exposes the lifecycle quartet", { timeout: 5000 }, () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
expect(typeof provider.lifecycle.install).toBe("function");
|
||||
expect(typeof provider.lifecycle.start).toBe("function");
|
||||
expect(typeof provider.lifecycle.stop).toBe("function");
|
||||
expect(typeof provider.lifecycle.uninstall).toBe("function");
|
||||
});
|
||||
|
||||
it("materialises Better-Auth during install", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
await provider.lifecycle.install({} as never);
|
||||
expect(cap.lastArgs()).toBeDefined();
|
||||
expect(cap.lastArgs()?.["baseURL"]).toBe("https://api.example.com");
|
||||
});
|
||||
|
||||
it("supports start/stop/uninstall as no-ops", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
await provider.lifecycle.install({} as never);
|
||||
await expect(
|
||||
provider.lifecycle.start({} as never)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
provider.lifecycle.stop({} as never)
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
provider.lifecycle.uninstall({} as never)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBetterAuthProvider — handler rewriting", () => {
|
||||
it("rewrites POST /login to /sign-in/email", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const login = recorded.find((r) => r.path === "/login");
|
||||
if (!login) throw new Error("/login not registered");
|
||||
const req = new Request("https://api.example.com/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: "a@b.c", password: "p" }),
|
||||
});
|
||||
const ctx = {
|
||||
request: req,
|
||||
params: {},
|
||||
} as unknown as never;
|
||||
await login.handler(ctx);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
expect(new URL(lastRequest.url).pathname).toBe("/sign-in/email");
|
||||
expect(lastRequest.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("rewrites GET /oauth/:provider/start with the matched provider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const start = recorded.find((r) => r.path === "/oauth/:provider/start");
|
||||
if (!start) throw new Error("oauth.start not registered");
|
||||
const req = new Request(
|
||||
"https://api.example.com/auth/oauth/github/start",
|
||||
{ method: "GET" }
|
||||
);
|
||||
await start.handler({
|
||||
request: req,
|
||||
params: { provider: "github" },
|
||||
} as unknown as never);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
expect(new URL(lastRequest.url).pathname).toBe(
|
||||
"/oauth2/sign-in/github"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves query string when rewriting", { timeout: 5000 }, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
|
||||
if (!cb) throw new Error("oauth.callback not registered");
|
||||
const req = new Request(
|
||||
"https://api.example.com/auth/oauth/github/callback?code=xxx&state=yyy",
|
||||
{ method: "GET" }
|
||||
);
|
||||
await cb.handler({
|
||||
request: req,
|
||||
params: { provider: "github" },
|
||||
} as unknown as never);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
const url = new URL(lastRequest.url);
|
||||
expect(url.pathname).toBe("/oauth2/callback/github");
|
||||
expect(url.searchParams.get("code")).toBe("xxx");
|
||||
expect(url.searchParams.get("state")).toBe("yyy");
|
||||
});
|
||||
|
||||
it("forwards POST body to the rewritten request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const register = recorded.find((r) => r.path === "/register");
|
||||
if (!register) throw new Error("/register not registered");
|
||||
const payload = { email: "a@b.c", password: "p", name: "A" };
|
||||
const req = new Request("https://api.example.com/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await register.handler({
|
||||
request: req,
|
||||
params: {},
|
||||
} as unknown as never);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
const echoed = (await lastRequest.text()) as string;
|
||||
expect(JSON.parse(echoed)).toEqual(payload);
|
||||
});
|
||||
|
||||
it("works with handlers having no body when GET-rewriting", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const refresh = recorded.find((r) => r.path === "/refresh");
|
||||
if (!refresh) throw new Error("/refresh not registered");
|
||||
const req = new Request("https://api.example.com/auth/refresh", {
|
||||
method: "POST",
|
||||
});
|
||||
const res = (await refresh.handler({
|
||||
request: req,
|
||||
params: {},
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("forwards a POST request with an empty body without setting init.body", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const logout = recorded.find((r) => r.path === "/logout");
|
||||
if (!logout) throw new Error("/logout not registered");
|
||||
// A POST request with no explicit body — `body` may be null OR
|
||||
// an empty stream depending on the runtime. The wrapper must
|
||||
// tolerate both.
|
||||
const req = new Request("https://api.example.com/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
await logout.handler({
|
||||
request: req,
|
||||
params: {},
|
||||
} as unknown as never);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
expect(new URL(lastRequest.url).pathname).toBe("/sign-out");
|
||||
});
|
||||
|
||||
it("rewrites with default empty params when ctx.params is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const verify = recorded.find((r) => r.path === "/verify-email/:token");
|
||||
if (!verify) throw new Error("verify-email not registered");
|
||||
const req = new Request(
|
||||
"https://api.example.com/auth/verify-email/tok",
|
||||
{ method: "GET" }
|
||||
);
|
||||
// Pass only `request`, no `params`, to exercise the `?? {}` branch.
|
||||
await verify.handler({ request: req } as unknown as never);
|
||||
const lastRequest = cap.lastRequest();
|
||||
if (!lastRequest) throw new Error("handler did not invoke auth");
|
||||
expect(new URL(lastRequest.url).pathname).toBe("/verify-email");
|
||||
});
|
||||
|
||||
it("OAuth start increments oauth_flows_total with outcome=success on 2xx", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory(
|
||||
new Response("ok", {
|
||||
status: 302,
|
||||
headers: { location: "/redirect" },
|
||||
})
|
||||
);
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const start = recorded.find((r) => r.path === "/oauth/:provider/start");
|
||||
if (!start) throw new Error("oauth.start not registered");
|
||||
const res = (await start.handler({
|
||||
request: new Request(
|
||||
"https://api.example.com/auth/oauth/github/start"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it("OAuth callback maps 403 to outcome=provider_rejected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory(new Response("forbidden", { status: 403 }));
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
|
||||
if (!cb) throw new Error("oauth.callback not registered");
|
||||
const res = (await cb.handler({
|
||||
request: new Request(
|
||||
"https://api.example.com/auth/oauth/github/callback?code=x&state=y"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("OAuth callback maps a 400 to outcome=state_mismatch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory(new Response("bad", { status: 400 }));
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
|
||||
if (!cb) throw new Error("oauth.callback not registered");
|
||||
const res = (await cb.handler({
|
||||
request: new Request(
|
||||
"https://api.example.com/auth/oauth/github/callback?state=mismatch"
|
||||
),
|
||||
params: { provider: "github" },
|
||||
} as unknown as never)) as Response;
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("OAuth callback without a matched :provider does not record outcome", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory(new Response("bad", { status: 400 }));
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const cb = recorded.find((r) => r.path === "/oauth/:provider/callback");
|
||||
if (!cb) throw new Error("oauth.callback not registered");
|
||||
await cb.handler({
|
||||
request: new Request(
|
||||
"https://api.example.com/auth/oauth/github/callback"
|
||||
),
|
||||
params: {},
|
||||
} as unknown as never);
|
||||
});
|
||||
|
||||
it("non-OAuth routes don't touch the OAuth counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cap = captureFactory();
|
||||
const provider = createBetterAuthProvider({
|
||||
...baseOpts,
|
||||
betterAuthFactory: cap.factory,
|
||||
});
|
||||
const recorded: RecordedRoute[] = [];
|
||||
provider.routes(fakeRouter(recorded), {} as never);
|
||||
const login = recorded.find((r) => r.path === "/login");
|
||||
if (!login) throw new Error("/login not registered");
|
||||
await login.handler({
|
||||
request: new Request("https://api.example.com/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "a@b.c", password: "p" }),
|
||||
}),
|
||||
params: {},
|
||||
} as unknown as never);
|
||||
// No oauth counter increment — assertion via no-throw.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import {
|
||||
type AuthPathPrefix,
|
||||
type AuthProviderAdapter,
|
||||
type AuthProviderLifecycle,
|
||||
authPathPrefix,
|
||||
PROVIDER_ROUTE_SURFACE,
|
||||
type ProviderRouteEntry,
|
||||
} from "@suaveplan/api-auth-provider";
|
||||
import type {
|
||||
CapabilityRequirements,
|
||||
RequestContext,
|
||||
Router,
|
||||
} from "@suaveplan/api-types";
|
||||
import { LazyBetterAuth } from "../db/database.js";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import { getBetterAuthInstruments } from "../instruments/instruments.js";
|
||||
import { defaultBetterAuthFactory } from "../oauth/default-factory.js";
|
||||
import { parseBetterAuthOptions } from "../options/options.js";
|
||||
import type { BetterAuthOptions } from "../options/options.types.js";
|
||||
import { assertRoutePrefixAvailable } from "../route-prefix/route-prefix.js";
|
||||
import { buildNativeUrl, ROUTE_TRANSLATION } from "./route-translation.js";
|
||||
|
||||
/**
|
||||
* Construct the `AuthProviderAdapter` half of the Better-Auth integration.
|
||||
*
|
||||
* The adapter mounts the canonical `PROVIDER_ROUTE_SURFACE` (9 routes)
|
||||
* under `routePrefix` (default `/auth`). Each handler internally rewrites
|
||||
* the incoming `Request` onto the Better-Auth native sub-path (see
|
||||
* `route-translation.ts`) and delegates to `auth.handler(...)` so
|
||||
* Better-Auth still owns the precise wire response (cookies, 302
|
||||
* redirects, error envelopes). Operators wiring this adapter MAY also wire
|
||||
* `createBetterAuthSession(opts)` against the same options bag —
|
||||
* the underlying Better-Auth instance is shared via a per-options
|
||||
* `WeakMap`.
|
||||
*/
|
||||
export function createBetterAuthProvider<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
>(rawOptions: BetterAuthOptions): AuthProviderAdapter<Caps> {
|
||||
const opts = parseBetterAuthOptions(rawOptions);
|
||||
const prefix = assertRoutePrefixAvailable(opts.routePrefix ?? "/auth");
|
||||
const branded: AuthPathPrefix = authPathPrefix(prefix);
|
||||
const factory = opts.betterAuthFactory ?? defaultBetterAuthFactory;
|
||||
const lazy = new LazyBetterAuth(opts, factory);
|
||||
|
||||
const lifecycle: AuthProviderLifecycle<Caps> = {
|
||||
install: async () => {
|
||||
// Materialise Better-Auth early so install-time DB issues fail
|
||||
// before the host accepts traffic.
|
||||
await lazy.get();
|
||||
},
|
||||
start: async () => {
|
||||
// No-op — routes are already registered through `routes(...)`
|
||||
// by the host driver before `start()` is called.
|
||||
},
|
||||
stop: async () => {
|
||||
// No-op — Better-Auth's underlying connection pool is owned by
|
||||
// the operator-supplied database value.
|
||||
},
|
||||
uninstall: async () => {
|
||||
lazy.reset();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: "better-auth",
|
||||
pathPrefix: branded,
|
||||
routes: (router, _ctx) => {
|
||||
registerRoutes<Caps>({
|
||||
router,
|
||||
lazy,
|
||||
opts,
|
||||
});
|
||||
},
|
||||
lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register every canonical route on the supplied `Router` slot. The slot
|
||||
* is already scoped to the provider's `pathPrefix` by the host, so the
|
||||
* paths supplied here are RELATIVE to the prefix.
|
||||
*/
|
||||
function registerRoutes<Caps extends CapabilityRequirements>(args: {
|
||||
readonly router: Router<Caps>;
|
||||
readonly lazy: LazyBetterAuth;
|
||||
readonly opts: BetterAuthOptions;
|
||||
}): void {
|
||||
for (const entry of PROVIDER_ROUTE_SURFACE) {
|
||||
const route: ProviderRouteEntry = entry;
|
||||
const translation = ROUTE_TRANSLATION[route.name];
|
||||
const handler = buildHandler<Caps>({
|
||||
lazy: args.lazy,
|
||||
opts: args.opts,
|
||||
translation,
|
||||
routeName: route.name,
|
||||
});
|
||||
switch (route.method) {
|
||||
case "GET":
|
||||
args.router.get(route.path, { auth: null }, handler as never);
|
||||
break;
|
||||
case "POST":
|
||||
args.router.post(route.path, { auth: null }, handler as never);
|
||||
break;
|
||||
/* istanbul ignore next -- the route-surface is closed and POST/GET-only; this arm exists only as exhaustive switch defence. */
|
||||
default:
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`unsupported method '${route.method}' in PROVIDER_ROUTE_SURFACE`,
|
||||
{ context: { route: route.name } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route handler factory. The returned function rewrites the incoming
|
||||
* canonical request onto the Better-Auth-native URL + method, then forwards
|
||||
* to `auth.handler(...)`. OAuth conclusions update the
|
||||
* `oauth_flows_total{provider,outcome}` counter.
|
||||
*/
|
||||
function buildHandler<Caps extends CapabilityRequirements>(args: {
|
||||
readonly lazy: LazyBetterAuth;
|
||||
readonly opts: BetterAuthOptions;
|
||||
readonly translation: (typeof ROUTE_TRANSLATION)[keyof typeof ROUTE_TRANSLATION];
|
||||
readonly routeName: string;
|
||||
}) {
|
||||
return async (ctx: RequestContext<Caps>): Promise<Response> => {
|
||||
const auth = await args.lazy.get();
|
||||
const incoming = ctx.request as Request;
|
||||
const inUrl = new URL(incoming.url);
|
||||
// The runtime `RequestContext` carries `params` even though the
|
||||
// abstract `@suaveplan/api-types` interface doesn't declare it —
|
||||
// see `api-host/request-pipeline-driver.ts` where the field is
|
||||
// attached. Cast through `unknown` to honour the runtime shape.
|
||||
const params =
|
||||
(
|
||||
ctx as unknown as {
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
}
|
||||
).params ?? {};
|
||||
const provider =
|
||||
typeof params["provider"] === "string"
|
||||
? params["provider"]
|
||||
: undefined;
|
||||
const nativeUrl = buildNativeUrl({
|
||||
baseUrl: args.opts.baseUrl,
|
||||
nativePath: args.translation.nativePath,
|
||||
params: params,
|
||||
search: inUrl.search,
|
||||
});
|
||||
const native = await rewriteRequest({
|
||||
original: incoming,
|
||||
url: nativeUrl,
|
||||
method: args.translation.nativeMethod,
|
||||
});
|
||||
const response = await auth.handler(native);
|
||||
recordOauthOutcome({
|
||||
routeName: args.routeName,
|
||||
provider,
|
||||
response,
|
||||
});
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the inbound `Request` onto the rewritten URL + method while
|
||||
* preserving headers and (for POST) the body. The body is read into a
|
||||
* `Uint8Array` so the rewritten request can be replayed without consuming
|
||||
* the original stream.
|
||||
*/
|
||||
async function rewriteRequest(args: {
|
||||
readonly original: Request;
|
||||
readonly url: URL;
|
||||
readonly method: "GET" | "POST";
|
||||
}): Promise<Request> {
|
||||
const init: RequestInit = {
|
||||
method: args.method,
|
||||
headers: args.original.headers,
|
||||
};
|
||||
if (args.method !== "GET" && args.original.body !== null) {
|
||||
const buf = await args.original.arrayBuffer();
|
||||
/* istanbul ignore else -- empty-body POSTs are exceptional; we still tolerate them by leaving init.body unset. */
|
||||
if (buf.byteLength > 0) {
|
||||
init.body = buf;
|
||||
}
|
||||
}
|
||||
return new Request(args.url.toString(), init);
|
||||
}
|
||||
|
||||
/**
|
||||
* On OAuth start/callback routes, project the response onto an `outcome`
|
||||
* label and increment `oauth_flows_total{provider,outcome}`. The
|
||||
* heuristic: 4xx with a `error=access_denied`-style body → provider
|
||||
* rejected; 4xx with `state` mismatch hint in URL → state mismatch; 2xx /
|
||||
* 3xx → success.
|
||||
*/
|
||||
function recordOauthOutcome(args: {
|
||||
readonly routeName: string;
|
||||
readonly provider: string | undefined;
|
||||
readonly response: Response;
|
||||
}): void {
|
||||
if (
|
||||
args.routeName !== "oauth.start" &&
|
||||
args.routeName !== "oauth.callback"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (args.provider === undefined) {
|
||||
return;
|
||||
}
|
||||
const instruments = getBetterAuthInstruments();
|
||||
let outcome: "success" | "state_mismatch" | "provider_rejected";
|
||||
if (args.response.status >= 200 && args.response.status < 400) {
|
||||
outcome = "success";
|
||||
} else if (args.response.status === 403) {
|
||||
outcome = "provider_rejected";
|
||||
} else {
|
||||
outcome = "state_mismatch";
|
||||
}
|
||||
instruments.oauthFlowsTotal.add(1, {
|
||||
provider: args.provider,
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createBetterAuthProvider } from "./create-provider.js";
|
||||
export {
|
||||
buildNativeUrl,
|
||||
type NativeRoute,
|
||||
ROUTE_TRANSLATION,
|
||||
} from "./route-translation.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `route-translation`
|
||||
|
||||
## Purpose
|
||||
|
||||
Translation table mapping every entry in `PROVIDER_ROUTE_SURFACE` (the
|
||||
canonical 9-route foundation surface) to the matching Better-Auth-native
|
||||
sub-path. The wrapper presents the canonical names outward while Better-Auth
|
||||
still drives the heavy lifting at its idiomatic paths
|
||||
(`/sign-up/email`, `/sign-in/email`, `/oauth2/sign-in/:providerId`, ...).
|
||||
Without this translation a host built on the foundation contracts would
|
||||
need to know Better-Auth-specific URLs — which would defeat the purpose of
|
||||
the abstract `auth.provider` capability.
|
||||
|
||||
## Features
|
||||
|
||||
- Single immutable map keyed by `ProviderRouteName`.
|
||||
- Carries both `nativePath` and `nativeMethod` so the wrapper can rewrite
|
||||
the request method too (some translations flip GET → POST).
|
||||
- `buildNativeUrl` helper rewrites the canonical request URL onto
|
||||
Better-Auth's base URL, resolving `:provider` / `:token` params and
|
||||
preserving the query string.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ROUTE_TRANSLATION, buildNativeUrl } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const target = ROUTE_TRANSLATION.login;
|
||||
// → { nativePath: "/sign-in/email", nativeMethod: "POST" }
|
||||
|
||||
const url = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: target.nativePath,
|
||||
params: {},
|
||||
search: "redirect=/home",
|
||||
});
|
||||
// → URL { https://api.example.com/sign-in/email?redirect=/home }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildNativeUrl, ROUTE_TRANSLATION } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
// OAuth callback — the canonical `/oauth/:provider/callback?code=xxx&state=yyy`
|
||||
// is rewritten to Better-Auth's `/oauth2/callback/:provider?...` surface.
|
||||
const target = ROUTE_TRANSLATION["oauth.callback"];
|
||||
const native = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: target.nativePath,
|
||||
params: { provider: "github" },
|
||||
search: "code=xxx&state=yyy",
|
||||
});
|
||||
// → URL { https://api.example.com/oauth2/callback/github?code=xxx&state=yyy }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `NativeRoute` | interface | `{ nativePath, nativeMethod }` |
|
||||
| `ROUTE_TRANSLATION` | `Readonly<Record<ProviderRouteName, NativeRoute>>` | Canonical → native map |
|
||||
| `buildNativeUrl(args)` | function | Rewrite the canonical URL onto Better-Auth's base URL |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`Object.freeze` on the outer record + a `Readonly<...>` cast — but not on
|
||||
the inner entries because each entry is already a frozen literal authored
|
||||
inline. Decided against `as const` on the outer block so the indexed key
|
||||
narrowing stays type-friendly under `noPropertyAccessFromIndexSignature`.
|
||||
`buildNativeUrl` runs `:param` substitution before constructing the URL so
|
||||
malformed param values can't break out of the path; `encodeURIComponent`
|
||||
defends against `/` injection in the `:provider` slot. Query string
|
||||
preservation is critical for OAuth — the entire upstream callback payload
|
||||
(`code`, `state`, `scope`, …) needs to land on Better-Auth's handler verbatim.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildNativeUrl, ROUTE_TRANSLATION } from "./route-translation.js";
|
||||
|
||||
describe("ROUTE_TRANSLATION", () => {
|
||||
it("covers every canonical PROVIDER_ROUTE_SURFACE name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"register",
|
||||
"login",
|
||||
"logout",
|
||||
"refresh",
|
||||
"oauth.start",
|
||||
"oauth.callback",
|
||||
"verify-email",
|
||||
"forgot-password",
|
||||
"reset-password",
|
||||
];
|
||||
for (const key of expected) {
|
||||
expect(ROUTE_TRANSLATION).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses POST for credential-mutation routes", { timeout: 5000 }, () => {
|
||||
expect(ROUTE_TRANSLATION.register.nativeMethod).toBe("POST");
|
||||
expect(ROUTE_TRANSLATION.login.nativeMethod).toBe("POST");
|
||||
expect(ROUTE_TRANSLATION.logout.nativeMethod).toBe("POST");
|
||||
expect(ROUTE_TRANSLATION["forgot-password"].nativeMethod).toBe("POST");
|
||||
expect(ROUTE_TRANSLATION["reset-password"].nativeMethod).toBe("POST");
|
||||
});
|
||||
|
||||
it("uses GET for the read-style routes", { timeout: 5000 }, () => {
|
||||
expect(ROUTE_TRANSLATION.refresh.nativeMethod).toBe("GET");
|
||||
expect(ROUTE_TRANSLATION["verify-email"].nativeMethod).toBe("GET");
|
||||
expect(ROUTE_TRANSLATION["oauth.callback"].nativeMethod).toBe("GET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeUrl", () => {
|
||||
it("rewrites a simple path onto the base URL", { timeout: 5000 }, () => {
|
||||
const url = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: "/sign-in/email",
|
||||
params: {},
|
||||
search: "",
|
||||
});
|
||||
expect(url.toString()).toBe("https://api.example.com/sign-in/email");
|
||||
});
|
||||
|
||||
it("substitutes :provider in OAuth paths", { timeout: 5000 }, () => {
|
||||
const url = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: "/oauth2/callback/:provider",
|
||||
params: { provider: "github" },
|
||||
search: "code=xxx&state=yyy",
|
||||
});
|
||||
expect(url.pathname).toBe("/oauth2/callback/github");
|
||||
expect(url.searchParams.get("code")).toBe("xxx");
|
||||
expect(url.searchParams.get("state")).toBe("yyy");
|
||||
});
|
||||
|
||||
it("URL-encodes the :provider value to defeat path-injection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: "/oauth2/callback/:provider",
|
||||
params: { provider: "../escape" },
|
||||
search: "",
|
||||
});
|
||||
expect(url.pathname).toBe("/oauth2/callback/..%2Fescape");
|
||||
});
|
||||
|
||||
it("supports search strings already prefixed with '?'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const url = buildNativeUrl({
|
||||
baseUrl: "https://api.example.com",
|
||||
nativePath: "/sign-in/email",
|
||||
params: {},
|
||||
search: "?redirect=/home",
|
||||
});
|
||||
expect(url.search).toBe("?redirect=/home");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ProviderRouteName } from "@suaveplan/api-auth-provider";
|
||||
|
||||
/**
|
||||
* Map every entry in `PROVIDER_ROUTE_SURFACE` to the Better-Auth-native
|
||||
* sub-path the wrapper rewrites incoming requests onto. The wrapper
|
||||
* presents the canonical foundation surface (`/register`, `/login`, ...)
|
||||
* outward while Better-Auth still drives the heavy lifting at its own
|
||||
* idiomatic paths (`/sign-up/email`, `/sign-in/email`, ...).
|
||||
*
|
||||
* The `:provider` segment in `/oauth/:provider/start` and
|
||||
* `/oauth/:provider/callback` is preserved verbatim so the genericOAuth
|
||||
* plugin's `/oauth2/sign-in/:providerId` and `/oauth2/callback/:providerId`
|
||||
* surface receives the matching provider id.
|
||||
*/
|
||||
export interface NativeRoute {
|
||||
/** Better-Auth-native pathname (relative — same shape as the canonical). */
|
||||
readonly nativePath: string;
|
||||
/** HTTP method Better-Auth expects for the rewritten request. */
|
||||
readonly nativeMethod: "GET" | "POST";
|
||||
}
|
||||
|
||||
/** Translation table — keyed by `ProviderRouteName`. */
|
||||
export const ROUTE_TRANSLATION: Readonly<
|
||||
Record<ProviderRouteName, NativeRoute>
|
||||
> = Object.freeze({
|
||||
register: {
|
||||
nativePath: "/sign-up/email",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
login: {
|
||||
nativePath: "/sign-in/email",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
logout: {
|
||||
nativePath: "/sign-out",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
refresh: {
|
||||
nativePath: "/get-session",
|
||||
nativeMethod: "GET",
|
||||
},
|
||||
"oauth.start": {
|
||||
// Built by `buildNativeRoute` from the matched `:provider` param.
|
||||
nativePath: "/oauth2/sign-in/:provider",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
"oauth.callback": {
|
||||
nativePath: "/oauth2/callback/:provider",
|
||||
nativeMethod: "GET",
|
||||
},
|
||||
"verify-email": {
|
||||
nativePath: "/verify-email",
|
||||
nativeMethod: "GET",
|
||||
},
|
||||
"forgot-password": {
|
||||
nativePath: "/forget-password",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
"reset-password": {
|
||||
nativePath: "/reset-password",
|
||||
nativeMethod: "POST",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Compute the rewritten URL Better-Auth's `handler(request)` will see for
|
||||
* a canonical route invocation. `baseUrl` is the Better-Auth base URL; the
|
||||
* canonical request path is rewritten to `${baseUrl}${nativePath}` with
|
||||
* `:provider` and `:token` placeholders resolved from the matched params.
|
||||
*/
|
||||
export function buildNativeUrl(args: {
|
||||
readonly baseUrl: string;
|
||||
readonly nativePath: string;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly search: string;
|
||||
}): URL {
|
||||
let resolved = args.nativePath;
|
||||
for (const [key, value] of Object.entries(args.params)) {
|
||||
resolved = resolved.replaceAll(`:${key}`, encodeURIComponent(value));
|
||||
}
|
||||
const url = new URL(resolved, args.baseUrl);
|
||||
if (args.search.length > 0) {
|
||||
url.search = args.search.startsWith("?")
|
||||
? args.search
|
||||
: `?${args.search}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { assertRoutePrefixAvailable } from "./route-prefix.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# `route-prefix`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolve an operator-supplied `routePrefix` against the host's reserved-prefix
|
||||
contract from `@suaveplan/api-host/reserved-prefix`. This guard ensures the
|
||||
Better-Auth provider cannot accidentally mount its account-lifecycle routes
|
||||
inside paths the host (or another first-party engine) reserves for itself —
|
||||
`/_host` (admin sub-router) and `/internal` (livez/readyz/metrics) today,
|
||||
plus whatever future entries land in `HOST_RESERVED_PREFIXES`. The check is
|
||||
performed at provider construction time so misconfiguration fails before the
|
||||
process is accepting traffic.
|
||||
|
||||
## Features
|
||||
|
||||
- Normalised prefix returned on success (lowercase, leading slash, no
|
||||
trailing slash) so downstream code can use it without re-normalising.
|
||||
- Collision detection covers both exact matches (`/internal`) and child
|
||||
paths (`/internal/livez`).
|
||||
- Reserved list NOT hard-coded — sourced from `@suaveplan/api-host` so the
|
||||
wrapper inherits future additions for free.
|
||||
- Throws `BetterAuthError(AUTH_BA.ROUTE_PREFIX_COLLISION)` with the
|
||||
normalised collision string in `context.prefix`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { assertRoutePrefixAvailable } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const prefix = assertRoutePrefixAvailable("/auth"); // → "/auth"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { assertRoutePrefixAvailable } from "@suaveplan/api-auth-better-auth";
|
||||
import { BetterAuthError, AUTH_BA_CODES } from "@suaveplan/api-auth-better-auth/error-codes";
|
||||
|
||||
try {
|
||||
const prefix = assertRoutePrefixAvailable(operatorConfig.routePrefix);
|
||||
return mountProvider({ prefix });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof BetterAuthError &&
|
||||
err.code === AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
|
||||
) {
|
||||
// The deploy script can report the collision back to the operator
|
||||
// before traffic is accepted.
|
||||
reportFatalConfigError({ requested: operatorConfig.routePrefix, code: err.code });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `assertRoutePrefixAvailable(prefix)` | `(string) => string` | Validates + returns normalised prefix |
|
||||
|
||||
The function throws `BetterAuthError(AUTH_BA.ROUTE_PREFIX_COLLISION)` when
|
||||
the prefix collides with `HOST_RESERVED_PREFIXES` from
|
||||
`@suaveplan/api-host/reserved-prefix`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy `codeFactory` callback pattern from `@suaveplan/api-host`'s
|
||||
`assertPrefixAvailable` API is critical here — the factory is only invoked on
|
||||
collision, so the `BetterAuthError` allocation (with its stack trace and
|
||||
context bag) is skipped on the happy path. The reserved-prefix module also
|
||||
exports `normalisePrefix` separately; this wrapper calls it to compute the
|
||||
returned canonical form because `assertPrefixAvailable` returns `void`. This
|
||||
matches the pattern in `@suaveplan/api-plugin-rbac` and other adapter
|
||||
packages that need both the throw-on-collision behaviour and the normalised
|
||||
output value at the call site.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import { assertRoutePrefixAvailable } from "./route-prefix.js";
|
||||
|
||||
describe("assertRoutePrefixAvailable", () => {
|
||||
it("accepts a free prefix and returns its normalised form", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(assertRoutePrefixAvailable("/auth")).toBe("/auth");
|
||||
expect(assertRoutePrefixAvailable("/auth/")).toBe("/auth");
|
||||
expect(assertRoutePrefixAvailable("/Auth")).toBe("/auth");
|
||||
});
|
||||
|
||||
it("accepts deeply-nested non-reserved prefixes", { timeout: 5000 }, () => {
|
||||
expect(assertRoutePrefixAvailable("/api/auth")).toBe("/api/auth");
|
||||
});
|
||||
|
||||
it("rejects collisions with /_host", { timeout: 5000 }, () => {
|
||||
try {
|
||||
assertRoutePrefixAvailable("/_host");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect((err as BetterAuthError).code).toBe(
|
||||
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects collisions with /internal", { timeout: 5000 }, () => {
|
||||
expect(() => assertRoutePrefixAvailable("/internal")).toThrow(
|
||||
BetterAuthError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects child paths of reserved prefixes", { timeout: 5000 }, () => {
|
||||
expect(() => assertRoutePrefixAvailable("/_host/auth")).toThrow(
|
||||
BetterAuthError
|
||||
);
|
||||
expect(() => assertRoutePrefixAvailable("/internal/auth")).toThrow(
|
||||
BetterAuthError
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the normalised collision string in error context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
assertRoutePrefixAvailable("/Internal/sub");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BetterAuthError);
|
||||
expect((err as BetterAuthError).context).toEqual({
|
||||
prefix: "/internal/sub",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
assertPrefixAvailable,
|
||||
normalisePrefix,
|
||||
} from "@suaveplan/api-host/reserved-prefix";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Validate an operator-supplied `routePrefix` against the host's reserved
|
||||
* prefix list. Returns the normalised canonical prefix (lowercase, leading
|
||||
* slash, no trailing slash) on success. Throws
|
||||
* {@link BetterAuthError} with code `AUTH_BA.ROUTE_PREFIX_COLLISION` when
|
||||
* the requested prefix collides with — or sits inside — a reserved prefix.
|
||||
*
|
||||
* The reserved-prefix list is sourced from
|
||||
* `@suaveplan/api-host/reserved-prefix` so the wrapper inherits any future
|
||||
* additions automatically without needing a coordinated release.
|
||||
*/
|
||||
export function assertRoutePrefixAvailable(prefix: string): string {
|
||||
assertPrefixAvailable(
|
||||
prefix,
|
||||
(collision) =>
|
||||
new BetterAuthError(
|
||||
AUTH_BA_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Better-Auth route prefix '${collision}' collides with a host-reserved prefix`,
|
||||
{ context: { prefix: collision } }
|
||||
)
|
||||
);
|
||||
return normalisePrefix(prefix);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# `create-session`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Factory for the `auth.session` half of The split. Returns a
|
||||
`SessionResolver` that asks Better-Auth's `auth.api.getSession({ headers })`
|
||||
to materialise the current session and then maps the resulting `(user,
|
||||
session)` tuple onto a `Principal` per `@suaveplan/api-types`. Returns `null`
|
||||
when Better-Auth reports no session — the host's resolver chain advances to
|
||||
the next adapter (typically `api-auth-anonymous`).
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the `SessionResolver` contract from `@suaveplan/api-auth`.
|
||||
- Pluggable `principalMapping` via `BetterAuthOptions.principalMapping`;
|
||||
default `mapBetterAuthUserToPrincipal`.
|
||||
- Lazy + cached Better-Auth instance — shared with
|
||||
`createBetterAuthProvider` when both factories receive the same options
|
||||
object (per-`opts` WeakMap).
|
||||
- OTel instruments emitted on every resolve: `sessions_resolved_total{outcome}`,
|
||||
`session_resolve_duration_ms{outcome}`,
|
||||
`principal_mapping_errors_total`.
|
||||
- Stable failure modes: `INVALID_SESSION`, `DB_UNREACHABLE`,
|
||||
`PRINCIPAL_MAPPING_FAILED`.
|
||||
- Disposable — `dispose()` is idempotent and turns subsequent `resolve()`
|
||||
calls into `null` returns.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
|
||||
|
||||
const resolver = createBetterAuthSession({
|
||||
database: pool,
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
});
|
||||
|
||||
// Inside the host's resolver chain:
|
||||
const principal = await resolver.resolve(request);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
|
||||
import { createAnonymousResolver } from "@suaveplan/api-auth-anonymous";
|
||||
|
||||
const resolver = new SessionResolverChain({
|
||||
resolvers: [
|
||||
createBetterAuthSession(opts),
|
||||
createAnonymousResolver(),
|
||||
],
|
||||
});
|
||||
|
||||
// The Better-Auth resolver fires first; if it returns `null`, the
|
||||
// anonymous tail takes over and the host's `publicRoutes` policy applies.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Shape | Purpose |
|
||||
| ------ | ----- | ------- |
|
||||
| `createBetterAuthSession(opts)` | `(BetterAuthOptions) => SessionResolver` | Build the resolver |
|
||||
|
||||
The resolver throws `BetterAuthError` with one of:
|
||||
|
||||
- `AUTH_BA.INVALID_SESSION` — unexpected upstream error from Better-Auth's
|
||||
`getSession`.
|
||||
- `AUTH_BA.DB_UNREACHABLE` — first-resolve build failed (wraps the
|
||||
underlying connection error as `cause`).
|
||||
- `AUTH_BA.PRINCIPAL_MAPPING_FAILED` — `principalMapping` threw; a partial
|
||||
principal is never returned.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`getSession` is consumed via the structural `BetterAuthLike` view declared
|
||||
in `../options/options.types.ts`. That view is intentionally narrow — only
|
||||
`handler` and `api.getSession` are referenced — so the unit tests can stub
|
||||
Better-Auth without touching the real runtime. The per-options WeakMap
|
||||
(`lazyByOptions`) is the linchpin of the "share an instance between session
|
||||
and provider" guarantee: passing the same `opts` object to both factories
|
||||
threads through the same `LazyBetterAuth`. The OTel `finally` block runs on
|
||||
every code path so the counters reflect every attempt, including failures
|
||||
that propagate as `BetterAuthError`. `dispose()` resets the cached
|
||||
Better-Auth instance via `lazy.reset()` and flips a disposed flag that
|
||||
neutralises any subsequent `resolve()` call. The default Better-Auth factory
|
||||
dynamically imports `better-auth` so consumers wiring a `betterAuthFactory`
|
||||
override avoid the cost of loading the upstream package altogether.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthSession } from "./create-session.js";
|
||||
|
||||
/**
|
||||
* Run `runAuthSessionConformance` against a fresh
|
||||
* `createBetterAuthSession` factory backed by a stubbed `BetterAuthLike`.
|
||||
*
|
||||
* Pinning the conformance suite against a stub instead of a live
|
||||
* Postgres container is the deliberate trade-off: the conformance suite
|
||||
* asserts the abstract `SessionResolver` contract (null on unrecognised
|
||||
* input, principal on recognised input, idempotent dispose), and the
|
||||
* presence or absence of a real database changes none of those
|
||||
* invariants. The full Postgres-backed end-to-end is covered by
|
||||
* `tests/integration/postgres-better-auth.test.ts`, which boots the
|
||||
* container + a real Better-Auth instance and exercises every wire path.
|
||||
*/
|
||||
|
||||
const KNOWN_USER: Principal = {
|
||||
id: "u_conformance" as never,
|
||||
scopes: ["test-scope"] as never[],
|
||||
claims: { id: "u_conformance", email: "conformance@example.com" },
|
||||
};
|
||||
|
||||
// The stubbed user surfaces `roles: ["test-scope"]` so the default mapping
|
||||
// projects exactly the expected scope list.
|
||||
|
||||
const optsBuilder = (): BetterAuthOptions => ({
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
betterAuthFactory: stubAuthFactory,
|
||||
});
|
||||
|
||||
function stubAuthFactory(_args: Record<string, unknown>): BetterAuthLike {
|
||||
return {
|
||||
handler: async () => new Response("ok"),
|
||||
api: {
|
||||
getSession: async (args: { readonly headers: Headers }) => {
|
||||
const cookie = args.headers.get("cookie") ?? "";
|
||||
if (cookie.includes("better-auth.session=valid")) {
|
||||
return {
|
||||
user: {
|
||||
id: "u_conformance",
|
||||
email: "conformance@example.com",
|
||||
roles: ["test-scope"],
|
||||
},
|
||||
session: { id: "s_conformance" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "createBetterAuthSession passes runAuthSessionConformance against Postgres-shaped stub",
|
||||
factory: () => createBetterAuthSession(optsBuilder()),
|
||||
seedNull: () =>
|
||||
new Request("https://api.example.com/anon", {
|
||||
headers: { cookie: "" },
|
||||
}),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://api.example.com/me", {
|
||||
headers: { cookie: "better-auth.session=valid" },
|
||||
}),
|
||||
expected: KNOWN_USER,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
BetterAuthLike,
|
||||
BetterAuthOptions,
|
||||
} from "../options/options.types.js";
|
||||
import { createBetterAuthSession } from "./create-session.js";
|
||||
|
||||
function buildOpts(
|
||||
overrides: Partial<BetterAuthOptions>,
|
||||
factory: (
|
||||
opts: Record<string, unknown>
|
||||
) => BetterAuthLike | Promise<BetterAuthLike>
|
||||
): BetterAuthOptions {
|
||||
return {
|
||||
database: { kind: "stub" },
|
||||
baseUrl: "https://api.example.com",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
betterAuthFactory: factory,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const knownUser = {
|
||||
id: "u_known",
|
||||
email: "known@example.com",
|
||||
roles: ["admin"],
|
||||
};
|
||||
|
||||
function stubAuthReturning(
|
||||
view: { user: typeof knownUser; session: { id: string } } | null
|
||||
): BetterAuthLike {
|
||||
return {
|
||||
handler: async () => new Response("ok"),
|
||||
api: { getSession: async () => view },
|
||||
};
|
||||
}
|
||||
|
||||
describe("createBetterAuthSession — pure resolve()", () => {
|
||||
it("maps a well-formed Better-Auth user to Principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts({}, () =>
|
||||
stubAuthReturning({
|
||||
user: knownUser,
|
||||
session: { id: "s_1" },
|
||||
})
|
||||
);
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
const principal = await resolver.resolve(
|
||||
new Request("https://api/me", {
|
||||
headers: { cookie: "better-auth.session=tok" },
|
||||
})
|
||||
);
|
||||
expect(principal).not.toBeNull();
|
||||
expect(principal?.id).toBe("u_known" as never);
|
||||
expect(principal?.scopes).toEqual(["admin"]);
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when Better-Auth reports no session", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts({}, () => stubAuthReturning(null));
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
const principal = await resolver.resolve(
|
||||
new Request("https://api/anon")
|
||||
);
|
||||
expect(principal).toBeNull();
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when the underlying API has no getSession", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts({}, () => ({
|
||||
handler: async () => new Response("ok"),
|
||||
api: {},
|
||||
}));
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
const principal = await resolver.resolve(
|
||||
new Request("https://api/me")
|
||||
);
|
||||
expect(principal).toBeNull();
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates AUTH_BA.PRINCIPAL_MAPPING_FAILED when mapping throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts(
|
||||
{
|
||||
principalMapping: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
},
|
||||
() =>
|
||||
stubAuthReturning({
|
||||
user: knownUser,
|
||||
session: { id: "s_1" },
|
||||
})
|
||||
);
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
await expect(
|
||||
resolver.resolve(new Request("https://api/me"))
|
||||
).rejects.toMatchObject({
|
||||
code: AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
});
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a BetterAuthError thrown by a custom mapping", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts(
|
||||
{
|
||||
principalMapping: () => {
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.USER_NOT_FOUND,
|
||||
"no record"
|
||||
);
|
||||
},
|
||||
},
|
||||
() =>
|
||||
stubAuthReturning({
|
||||
user: knownUser,
|
||||
session: { id: "s_1" },
|
||||
})
|
||||
);
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
await expect(
|
||||
resolver.resolve(new Request("https://api/me"))
|
||||
).rejects.toMatchObject({ code: AUTH_BA_CODES.USER_NOT_FOUND });
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps factory errors as AUTH_BA.DB_UNREACHABLE on first resolve", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts({}, () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
});
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
await expect(
|
||||
resolver.resolve(new Request("https://api/me"))
|
||||
).rejects.toMatchObject({ code: AUTH_BA_CODES.DB_UNREACHABLE });
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps unexpected non-BetterAuth getSession errors as INVALID_SESSION", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const opts = buildOpts({}, () => ({
|
||||
handler: async () => new Response("ok"),
|
||||
api: {
|
||||
getSession: async () => {
|
||||
throw new Error("upstream blew up");
|
||||
},
|
||||
},
|
||||
}));
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
try {
|
||||
await expect(
|
||||
resolver.resolve(new Request("https://api/me"))
|
||||
).rejects.toMatchObject({ code: AUTH_BA_CODES.INVALID_SESSION });
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispose() is idempotent", { timeout: 5000 }, async () => {
|
||||
const opts = buildOpts({}, () => stubAuthReturning(null));
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
await resolver.dispose();
|
||||
await expect(resolver.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null after dispose without touching the underlying instance", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const opts = buildOpts({}, () => {
|
||||
calls += 1;
|
||||
return stubAuthReturning({
|
||||
user: knownUser,
|
||||
session: { id: "s_1" },
|
||||
});
|
||||
});
|
||||
const resolver = createBetterAuthSession(opts);
|
||||
await resolver.dispose();
|
||||
const principal = await resolver.resolve(new Request("https://api/me"));
|
||||
expect(principal).toBeNull();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("each createBetterAuthSession call yields an independent LazyBetterAuth", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const opts = buildOpts({}, () => {
|
||||
calls += 1;
|
||||
return stubAuthReturning({
|
||||
user: knownUser,
|
||||
session: { id: "s_1" },
|
||||
});
|
||||
});
|
||||
// Two resolvers — each builds its own Better-Auth instance. The
|
||||
// operator controls sharing by passing the same `LazyBetterAuth`
|
||||
// construction through their own factory if they want one pool.
|
||||
const a = createBetterAuthSession(opts);
|
||||
const b = createBetterAuthSession(opts);
|
||||
try {
|
||||
await a.resolve(new Request("https://api/me"));
|
||||
await b.resolve(new Request("https://api/me"));
|
||||
expect(calls).toBe(2);
|
||||
} finally {
|
||||
await a.dispose();
|
||||
await b.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SessionResolver } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { LazyBetterAuth } from "../db/database.js";
|
||||
import { AUTH_BA_CODES, BetterAuthError } from "../error-codes/error-codes.js";
|
||||
import { getBetterAuthInstruments } from "../instruments/instruments.js";
|
||||
import { defaultBetterAuthFactory } from "../oauth/default-factory.js";
|
||||
import { parseBetterAuthOptions } from "../options/options.js";
|
||||
import type { BetterAuthOptions } from "../options/options.types.js";
|
||||
import { mapBetterAuthUserToPrincipal } from "../principal/map-user-to-principal.js";
|
||||
|
||||
/**
|
||||
* Construct a `SessionResolver` that asks Better-Auth's `auth.api.getSession`
|
||||
* to materialise the current session from the request's cookie/bearer, then
|
||||
* maps the resulting `(user, session)` view onto a `Principal` per
|
||||
* `@suaveplan/api-types`. Returns `null` when Better-Auth reports no
|
||||
* session — the host's resolver chain then advances to the next adapter
|
||||
* (typically `api-auth-anonymous`).
|
||||
*
|
||||
* Operators MAY share the underlying Better-Auth instance with
|
||||
* `createBetterAuthProvider(opts)` by passing the same options bag — the
|
||||
* factories memoise per-options so both sides resolve through one pool.
|
||||
* Wiring only the session adapter is the documented "Better-Auth for
|
||||
* session reads, Clerk for account lifecycle" topology and works without
|
||||
* standing up the provider routes.
|
||||
*/
|
||||
export function createBetterAuthSession(
|
||||
rawOptions: BetterAuthOptions
|
||||
): SessionResolver {
|
||||
const opts = parseBetterAuthOptions(rawOptions);
|
||||
const factory = opts.betterAuthFactory ?? defaultBetterAuthFactory;
|
||||
const lazy = new LazyBetterAuth(opts, factory);
|
||||
const map = opts.principalMapping ?? mapBetterAuthUserToPrincipal;
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
async resolve(req: Request): Promise<Principal | null> {
|
||||
if (disposed) return null;
|
||||
const instruments = getBetterAuthInstruments();
|
||||
const started = nowMs();
|
||||
let outcome: "resolved" | "null" | "error" = "null";
|
||||
try {
|
||||
const auth = await lazy.get();
|
||||
const getSession = auth.api.getSession;
|
||||
if (getSession === undefined) {
|
||||
return null;
|
||||
}
|
||||
const sessionView = await getSession({
|
||||
headers: req.headers,
|
||||
});
|
||||
if (sessionView === null) {
|
||||
return null;
|
||||
}
|
||||
let principal: Principal;
|
||||
try {
|
||||
principal = map(sessionView);
|
||||
} catch (err) {
|
||||
instruments.principalMappingErrorsTotal.add(1);
|
||||
outcome = "error";
|
||||
throw err instanceof BetterAuthError
|
||||
? err
|
||||
: new BetterAuthError(
|
||||
AUTH_BA_CODES.PRINCIPAL_MAPPING_FAILED,
|
||||
"principalMapping threw during session resolution",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
outcome = "resolved";
|
||||
return principal;
|
||||
} catch (err) {
|
||||
outcome = "error";
|
||||
if (err instanceof BetterAuthError) {
|
||||
throw err;
|
||||
}
|
||||
throw new BetterAuthError(
|
||||
AUTH_BA_CODES.INVALID_SESSION,
|
||||
"Better-Auth session resolution failed",
|
||||
{ cause: err }
|
||||
);
|
||||
} finally {
|
||||
const duration = nowMs() - started;
|
||||
instruments.sessionsResolvedTotal.add(1, { outcome });
|
||||
instruments.sessionResolveDurationMs.record(duration, {
|
||||
outcome,
|
||||
});
|
||||
}
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lazy.reset();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* istanbul ignore next -- `performance` is universally present on Node 18+ + Bun; the Date.now() fallback exists only as a defensive guard for stripped-down runtimes. */
|
||||
function nowMs(): number {
|
||||
return typeof performance === "object" &&
|
||||
typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createBetterAuthSession } from "./create-session.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type AuthProviderConformanceFactory,
|
||||
type AuthProviderConformanceOptions,
|
||||
type AuthSessionConformanceFactory,
|
||||
createBetterAuthProvider,
|
||||
createBetterAuthSession,
|
||||
runAuthProviderConformance,
|
||||
runAuthSessionConformance,
|
||||
} from "./testing.js";
|
||||
@@ -0,0 +1,90 @@
|
||||
# `testing`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`@suaveplan/api-auth-better-auth/testing` is the subpath downstream packages
|
||||
import when they want to assert their own wiring layer still meets the
|
||||
abstract `auth.session` and `auth.provider` contracts on top of this
|
||||
adapter. It re-exports both conformance harnesses
|
||||
(`runAuthSessionConformance` from `@suaveplan/api-auth` and
|
||||
`runAuthProviderConformance` from `@suaveplan/api-auth-provider`) alongside
|
||||
the two Better-Auth factories from this package, so a single import gets
|
||||
the consumer everything they need.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports `runAuthSessionConformance` + `runAuthProviderConformance` from
|
||||
the contract packages so consumers don't have to add those packages to
|
||||
their own dependency closure.
|
||||
- Re-exports `createBetterAuthSession` + `createBetterAuthProvider`
|
||||
so the conformance call sites can drop the factory inline.
|
||||
- `AuthSessionConformanceFactory` / `AuthProviderConformanceFactory` /
|
||||
`AuthProviderConformanceOptions` type aliases for explicit typing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBetterAuthProvider,
|
||||
runAuthProviderConformance,
|
||||
} from "@suaveplan/api-auth-better-auth/testing";
|
||||
|
||||
const results = runAuthProviderConformance({
|
||||
label: "my-host's Better-Auth wiring",
|
||||
factory: () => createBetterAuthProvider(myOptions),
|
||||
});
|
||||
for (const scenario of results) {
|
||||
it(`${scenario.category} :: ${scenario.name}`, () => {
|
||||
expect(scenario.passed).toBe(true);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBetterAuthSession,
|
||||
runAuthSessionConformance,
|
||||
type AuthSessionConformanceFactory,
|
||||
} from "@suaveplan/api-auth-better-auth/testing";
|
||||
|
||||
const spec: AuthSessionConformanceFactory = {
|
||||
label: "Postgres-backed Better-Auth + my custom mapping",
|
||||
factory: () => createBetterAuthSession(myOptionsWithCustomMapping),
|
||||
seedNull: () => new Request("https://api.example.com/anon"),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://api.example.com/me", {
|
||||
headers: { cookie: "better-auth.session=valid" },
|
||||
}),
|
||||
expected: knownPrincipal,
|
||||
}),
|
||||
};
|
||||
runAuthSessionConformance(spec);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Source | Purpose |
|
||||
| ------ | ------ | ------- |
|
||||
| `runAuthSessionConformance` | `@suaveplan/api-auth` | Session contract harness |
|
||||
| `runAuthProviderConformance` | `@suaveplan/api-auth-provider` | Provider contract harness |
|
||||
| `createBetterAuthSession` | this package | Session factory under test |
|
||||
| `createBetterAuthProvider` | this package | Provider factory under test |
|
||||
| `AuthSessionConformanceFactory` | type | Spec passed to session harness |
|
||||
| `AuthProviderConformanceFactory` | type | Factory signature for provider harness |
|
||||
| `AuthProviderConformanceOptions` | type | Options for provider harness |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The subpath is intentionally light — most of the value is just having one
|
||||
import path consumers can pull from. The conformance harnesses themselves
|
||||
live in the contract packages because that's where the abstract invariants
|
||||
are authored; this package's contribution to "passing conformance" is the
|
||||
factory pair. Consumers test their own wiring layer (custom middleware,
|
||||
host configuration, plugin order) by invoking the harnesses with their
|
||||
own options bag and asserting every `ConformanceScenarioResult.passed`.
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* `@suaveplan/api-auth-better-auth/testing` — re-exports of both conformance
|
||||
* suites bound to factories that produce a real Better-Auth-backed
|
||||
* `SessionResolver` and `AuthProviderAdapter` against a Postgres-shaped
|
||||
* configuration. Consumers of this subpath drop these factories into their
|
||||
* own test files when they want to verify their wiring layer still meets
|
||||
* the abstract contracts.
|
||||
*/
|
||||
|
||||
export {
|
||||
type AuthSessionConformanceFactory,
|
||||
runAuthSessionConformance,
|
||||
} from "@suaveplan/api-auth";
|
||||
export {
|
||||
type AuthProviderConformanceFactory,
|
||||
type AuthProviderConformanceOptions,
|
||||
runAuthProviderConformance,
|
||||
} from "@suaveplan/api-auth-provider";
|
||||
export { createBetterAuthProvider } from "../provider/create-provider.js";
|
||||
export { createBetterAuthSession } from "../session/create-session.js";
|
||||
Reference in New Issue
Block a user