@nifrajs/better-auth 1.12.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +94 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +150 -1
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AnyServer, type Server } from "@nifrajs/core/server";
|
|
1
2
|
/**
|
|
2
3
|
* The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
|
|
3
4
|
* Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
|
|
@@ -49,7 +50,7 @@ export interface BetterAuthOptions {
|
|
|
49
50
|
* Idempotent (named `"better-auth"` — applying twice mounts once). Read the session with
|
|
50
51
|
* {@link getSession} / {@link requireSession}, which infer your better-auth types.
|
|
51
52
|
*/
|
|
52
|
-
export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core").IdentityPlugin;
|
|
53
|
+
export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core/server").IdentityPlugin;
|
|
53
54
|
/**
|
|
54
55
|
* Resolve the better-auth session for a request — a thin, typed wrapper over `auth.api.getSession`.
|
|
55
56
|
* Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
|
|
@@ -78,4 +79,96 @@ export interface RequireSessionOptions {
|
|
|
78
79
|
* ```
|
|
79
80
|
*/
|
|
80
81
|
export declare function requireSession<A extends BetterAuthLike>(auth: A, request: Request, options?: RequireSessionOptions): Promise<SessionOf<A>>;
|
|
82
|
+
/**
|
|
83
|
+
* The authenticated caller of a request, mapped from a better-auth session. Built by
|
|
84
|
+
* {@link requirePrincipal} / {@link authed} and threaded onto the handler context as `c.principal`.
|
|
85
|
+
*
|
|
86
|
+
* `tenantId` is optional here (`string | undefined`); with `{ requireTenant: true }` it is narrowed to a
|
|
87
|
+
* non-optional `string` (see {@link authed}), so a tenant-scoped handler never has to null-check it.
|
|
88
|
+
* nifra owns the session -> principal wiring ONLY; binding the principal to a DB/RLS scope stays in
|
|
89
|
+
* userland (nifra is storage-agnostic and adds no DB code).
|
|
90
|
+
*/
|
|
91
|
+
export interface Principal<User> {
|
|
92
|
+
/** The full better-auth user record, typed from the concrete auth instance. */
|
|
93
|
+
readonly user: User;
|
|
94
|
+
/** The user's id (`user.id`). */
|
|
95
|
+
readonly userId: string;
|
|
96
|
+
/** The session's id (`session.id`). */
|
|
97
|
+
readonly sessionId: string;
|
|
98
|
+
/** The resolved tenant/org id, if any. Non-optional `string` under `{ requireTenant: true }`. */
|
|
99
|
+
readonly tenantId?: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The non-null user type of a concrete better-auth instance `A` (`SessionOf<A>["user"]`). Collapses to
|
|
103
|
+
* `unknown` only for the erased structural `BetterAuthLike`; a real instance recovers the concrete user.
|
|
104
|
+
*/
|
|
105
|
+
export type SessionUserOf<A extends BetterAuthLike> = SessionOf<A> extends {
|
|
106
|
+
user: infer U;
|
|
107
|
+
} ? U : unknown;
|
|
108
|
+
/**
|
|
109
|
+
* Options for {@link requirePrincipal} / {@link authed}.
|
|
110
|
+
*/
|
|
111
|
+
export interface AuthedOptions<User> {
|
|
112
|
+
/** Require a resolvable tenant. When set and no tenant resolves, fail closed with `403`. */
|
|
113
|
+
readonly requireTenant?: boolean;
|
|
114
|
+
/** Browser redirect target (`302`) for a missing session, instead of the default `401` JSON. Same-origin. */
|
|
115
|
+
readonly redirectTo?: string;
|
|
116
|
+
/** Resolve the tenant id from the user. Default: `user.tenantId ?? user.orgId` (string-valued only). */
|
|
117
|
+
readonly tenantOf?: (user: User) => string | undefined;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The principal type for a given `requireTenant` flag: `tenantId` narrows to a required `string` when
|
|
121
|
+
* `requireTenant` is `true`, otherwise stays optional (`string | undefined`). The flag is captured as a
|
|
122
|
+
* literal `const` type parameter at the call sites so `{ requireTenant: true }` selects the narrowed branch.
|
|
123
|
+
*/
|
|
124
|
+
export type PrincipalFor<User, RequireTenant extends boolean> = RequireTenant extends true ? Principal<User> & {
|
|
125
|
+
readonly tenantId: string;
|
|
126
|
+
} : Principal<User>;
|
|
127
|
+
/** Add `{ principal: P }` to a server's context while preserving its route registry `R` (no collapse to
|
|
128
|
+
* `any`). This is the type that makes `.use(authed(auth))` thread a NON-NULL `c.principal`. */
|
|
129
|
+
export type WithPrincipal<S extends AnyServer, P> = S extends Server<infer R, infer C> ? Server<R, C & {
|
|
130
|
+
principal: P;
|
|
131
|
+
}> : never;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
|
|
134
|
+
* handler never runs unauthenticated:
|
|
135
|
+
*
|
|
136
|
+
* - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
|
|
137
|
+
* - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
|
|
138
|
+
*
|
|
139
|
+
* nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
|
|
140
|
+
* or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
|
|
144
|
+
* // principal.userId / principal.tenantId are typed `string`, no null-check
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export declare function requirePrincipal<A extends BetterAuthLike, const RequireTenant extends boolean = false>(auth: A, request: Request, options?: AuthedOptions<SessionUserOf<A>> & {
|
|
148
|
+
readonly requireTenant?: RequireTenant;
|
|
149
|
+
}): Promise<PrincipalFor<SessionUserOf<A>, RequireTenant>>;
|
|
150
|
+
/**
|
|
151
|
+
* A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
|
|
152
|
+
* `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
|
|
153
|
+
* and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
|
|
154
|
+
* forgotten. Works in both modes:
|
|
155
|
+
*
|
|
156
|
+
* ```ts
|
|
157
|
+
* // inline
|
|
158
|
+
* const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
|
|
159
|
+
* // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
|
|
160
|
+
* const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
|
|
164
|
+
*
|
|
165
|
+
* DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
|
|
166
|
+
* a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
|
|
167
|
+
* overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
|
|
168
|
+
* Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
|
|
169
|
+
* twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
|
|
170
|
+
*/
|
|
171
|
+
export declare function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(auth: A, options?: AuthedOptions<SessionUserOf<A>> & {
|
|
172
|
+
readonly requireTenant?: RequireTenant;
|
|
173
|
+
}): <S extends AnyServer>(app: S) => WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>;
|
|
81
174
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAwB,KAAK,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAExF;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACpE,QAAQ,CAAC,GAAG,EAAE;QACZ,+FAA+F;QAC/F,QAAQ,CAAC,UAAU,EAAE,CAAC,OAAO,EAAE;YAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;SAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;KAClF,CAAA;IACD;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC5E;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,cAAc,IAAI,WAAW,CAC3D,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAC5C,CAAA;AAED,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAKD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,GAAE,iBAAsB,iDAiB/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,cAAc,EACjD,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAI9B;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAeD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,CAAC,SAAS,cAAc,EAC3D,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAIvB;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,SAAS,CAAC,IAAI;IAC7B,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;IACnB,iCAAiC;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,iGAAiG;IACjG,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED;;;GAGG;AACH,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,cAAc,IAChD,SAAS,CAAC,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,CAAC,GAAG,OAAO,CAAA;AAEtD;;GAEG;AACH,MAAM,WAAW,aAAa,CAAC,IAAI;IACjC,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAA;IAChC,6GAA6G;IAC7G,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,wGAAwG;IACxG,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,GAAG,SAAS,CAAA;CACvD;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,CAAC,IAAI,EAAE,aAAa,SAAS,OAAO,IAAI,aAAa,SAAS,IAAI,GACtF,SAAS,CAAC,IAAI,CAAC,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC/C,SAAS,CAAC,IAAI,CAAC,CAAA;AAEnB;+FAC+F;AAC/F,MAAM,MAAM,aAAa,CAAC,CAAC,SAAS,SAAS,EAAE,CAAC,IAC9C,CAAC,SAAS,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG;IAAE,SAAS,EAAE,CAAC,CAAA;CAAE,CAAC,GAAG,KAAK,CAAA;AAI9E;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,gBAAgB,CACpC,CAAC,SAAS,cAAc,EACxB,KAAK,CAAC,aAAa,SAAS,OAAO,GAAG,KAAK,EAE3C,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAA;CAAE,GACrF,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC,CAqCxD;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,MAAM,CAAC,CAAC,SAAS,cAAc,EAAE,KAAK,CAAC,aAAa,SAAS,OAAO,GAAG,KAAK,EAC1F,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,GAAG;IAAE,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAA;CAAE,GACrF,CAAC,CAAC,SAAS,SAAS,EACrB,GAAG,EAAE,CAAC,KACH,aAAa,CAAC,CAAC,EAAE,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC,CAKnE"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineIdentityPlugin } from "@nifrajs/core";
|
|
1
|
+
import { defineIdentityPlugin } from "@nifrajs/core/server";
|
|
2
2
|
// better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
|
|
3
3
|
const HANDLED_METHODS = ["GET", "POST"];
|
|
4
4
|
/**
|
|
@@ -74,4 +74,73 @@ export async function requireSession(auth, request, options = {}) {
|
|
|
74
74
|
return session;
|
|
75
75
|
throw rejection(options);
|
|
76
76
|
}
|
|
77
|
+
const forbidden = () => Response.json({ ok: false, error: "forbidden" }, { status: 403 });
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
|
|
80
|
+
* handler never runs unauthenticated:
|
|
81
|
+
*
|
|
82
|
+
* - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
|
|
83
|
+
* - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
|
|
84
|
+
*
|
|
85
|
+
* nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
|
|
86
|
+
* or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
|
|
90
|
+
* // principal.userId / principal.tenantId are typed `string`, no null-check
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export async function requirePrincipal(auth, request, options) {
|
|
94
|
+
// Reuse requireSession's throw path (302/redirect or 401) verbatim - one owner for the no-session gate.
|
|
95
|
+
const session = await requireSession(auth, request, options?.redirectTo !== undefined ? { redirectTo: options.redirectTo } : {});
|
|
96
|
+
// better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
|
|
97
|
+
// fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
|
|
98
|
+
const view = session;
|
|
99
|
+
const user = view.user;
|
|
100
|
+
const userId = user.id;
|
|
101
|
+
const sessionId = view.session.id;
|
|
102
|
+
const resolveTenant = options?.tenantOf ??
|
|
103
|
+
((u) => {
|
|
104
|
+
const record = u;
|
|
105
|
+
const value = record.tenantId ?? record.orgId;
|
|
106
|
+
return typeof value === "string" ? value : undefined;
|
|
107
|
+
});
|
|
108
|
+
// A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
|
|
109
|
+
// returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
|
|
110
|
+
const resolved = resolveTenant(user);
|
|
111
|
+
const tenantId = resolved === undefined || resolved === "" ? undefined : resolved;
|
|
112
|
+
if (options?.requireTenant === true && tenantId === undefined)
|
|
113
|
+
throw forbidden();
|
|
114
|
+
// Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
|
|
115
|
+
// single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
|
|
116
|
+
// shape is exactly the mapped session, no untrusted data crosses here.
|
|
117
|
+
const principal = tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId };
|
|
118
|
+
return principal;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
|
|
122
|
+
* `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
|
|
123
|
+
* and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
|
|
124
|
+
* forgotten. Works in both modes:
|
|
125
|
+
*
|
|
126
|
+
* ```ts
|
|
127
|
+
* // inline
|
|
128
|
+
* const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
|
|
129
|
+
* // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
|
|
130
|
+
* const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
|
|
134
|
+
*
|
|
135
|
+
* DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
|
|
136
|
+
* a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
|
|
137
|
+
* overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
|
|
138
|
+
* Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
|
|
139
|
+
* twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
|
|
140
|
+
*/
|
|
141
|
+
export function authed(auth, options) {
|
|
142
|
+
return (app) => app.derive(async (c) => ({
|
|
143
|
+
principal: await requirePrincipal(auth, c.req, options),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
77
146
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,oBAAoB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/better-auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Mount better-auth into a nifra app — one app.use() wires /api/auth/*, plus typed getSession/requireSession guards. Structural typing: no hard dependency on better-auth.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@nifrajs/core": "^
|
|
43
|
+
"@nifrajs/core": "^2.0.0",
|
|
44
44
|
"better-auth": ">=1.0.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependenciesMeta": {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AnyServer, defineIdentityPlugin } from "@nifrajs/core"
|
|
1
|
+
import { type AnyServer, defineIdentityPlugin, type Server } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
|
|
@@ -132,3 +132,152 @@ export async function requireSession<A extends BetterAuthLike>(
|
|
|
132
132
|
if (session !== null) return session
|
|
133
133
|
throw rejection(options)
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The authenticated caller of a request, mapped from a better-auth session. Built by
|
|
138
|
+
* {@link requirePrincipal} / {@link authed} and threaded onto the handler context as `c.principal`.
|
|
139
|
+
*
|
|
140
|
+
* `tenantId` is optional here (`string | undefined`); with `{ requireTenant: true }` it is narrowed to a
|
|
141
|
+
* non-optional `string` (see {@link authed}), so a tenant-scoped handler never has to null-check it.
|
|
142
|
+
* nifra owns the session -> principal wiring ONLY; binding the principal to a DB/RLS scope stays in
|
|
143
|
+
* userland (nifra is storage-agnostic and adds no DB code).
|
|
144
|
+
*/
|
|
145
|
+
export interface Principal<User> {
|
|
146
|
+
/** The full better-auth user record, typed from the concrete auth instance. */
|
|
147
|
+
readonly user: User
|
|
148
|
+
/** The user's id (`user.id`). */
|
|
149
|
+
readonly userId: string
|
|
150
|
+
/** The session's id (`session.id`). */
|
|
151
|
+
readonly sessionId: string
|
|
152
|
+
/** The resolved tenant/org id, if any. Non-optional `string` under `{ requireTenant: true }`. */
|
|
153
|
+
readonly tenantId?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The non-null user type of a concrete better-auth instance `A` (`SessionOf<A>["user"]`). Collapses to
|
|
158
|
+
* `unknown` only for the erased structural `BetterAuthLike`; a real instance recovers the concrete user.
|
|
159
|
+
*/
|
|
160
|
+
export type SessionUserOf<A extends BetterAuthLike> =
|
|
161
|
+
SessionOf<A> extends { user: infer U } ? U : unknown
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Options for {@link requirePrincipal} / {@link authed}.
|
|
165
|
+
*/
|
|
166
|
+
export interface AuthedOptions<User> {
|
|
167
|
+
/** Require a resolvable tenant. When set and no tenant resolves, fail closed with `403`. */
|
|
168
|
+
readonly requireTenant?: boolean
|
|
169
|
+
/** Browser redirect target (`302`) for a missing session, instead of the default `401` JSON. Same-origin. */
|
|
170
|
+
readonly redirectTo?: string
|
|
171
|
+
/** Resolve the tenant id from the user. Default: `user.tenantId ?? user.orgId` (string-valued only). */
|
|
172
|
+
readonly tenantOf?: (user: User) => string | undefined
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The principal type for a given `requireTenant` flag: `tenantId` narrows to a required `string` when
|
|
177
|
+
* `requireTenant` is `true`, otherwise stays optional (`string | undefined`). The flag is captured as a
|
|
178
|
+
* literal `const` type parameter at the call sites so `{ requireTenant: true }` selects the narrowed branch.
|
|
179
|
+
*/
|
|
180
|
+
export type PrincipalFor<User, RequireTenant extends boolean> = RequireTenant extends true
|
|
181
|
+
? Principal<User> & { readonly tenantId: string }
|
|
182
|
+
: Principal<User>
|
|
183
|
+
|
|
184
|
+
/** Add `{ principal: P }` to a server's context while preserving its route registry `R` (no collapse to
|
|
185
|
+
* `any`). This is the type that makes `.use(authed(auth))` thread a NON-NULL `c.principal`. */
|
|
186
|
+
export type WithPrincipal<S extends AnyServer, P> =
|
|
187
|
+
S extends Server<infer R, infer C> ? Server<R, C & { principal: P }> : never
|
|
188
|
+
|
|
189
|
+
const forbidden = (): Response => Response.json({ ok: false, error: "forbidden" }, { status: 403 })
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the better-auth session and map it to a {@link Principal}, or **throw a `Response`** so the
|
|
193
|
+
* handler never runs unauthenticated:
|
|
194
|
+
*
|
|
195
|
+
* - No/invalid session -> `302` to `options.redirectTo` when set, else `401` JSON (`requireSession`).
|
|
196
|
+
* - `requireTenant: true` and no tenant resolves -> `403` JSON (`{ ok: false, error: "forbidden" }`).
|
|
197
|
+
*
|
|
198
|
+
* nifra returns a thrown `Response` as-is (short-circuit), so this is the fail-closed guard used inline
|
|
199
|
+
* or by {@link authed}. `tenantId` is a non-optional `string` in the return type when `requireTenant`.
|
|
200
|
+
*
|
|
201
|
+
* ```ts
|
|
202
|
+
* const principal = await requirePrincipal(auth, c.req, { requireTenant: true })
|
|
203
|
+
* // principal.userId / principal.tenantId are typed `string`, no null-check
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export async function requirePrincipal<
|
|
207
|
+
A extends BetterAuthLike,
|
|
208
|
+
const RequireTenant extends boolean = false,
|
|
209
|
+
>(
|
|
210
|
+
auth: A,
|
|
211
|
+
request: Request,
|
|
212
|
+
options?: AuthedOptions<SessionUserOf<A>> & { readonly requireTenant?: RequireTenant },
|
|
213
|
+
): Promise<PrincipalFor<SessionUserOf<A>, RequireTenant>> {
|
|
214
|
+
// Reuse requireSession's throw path (302/redirect or 401) verbatim - one owner for the no-session gate.
|
|
215
|
+
const session = await requireSession(
|
|
216
|
+
auth,
|
|
217
|
+
request,
|
|
218
|
+
options?.redirectTo !== undefined ? { redirectTo: options.redirectTo } : {},
|
|
219
|
+
)
|
|
220
|
+
// better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
|
|
221
|
+
// fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
|
|
222
|
+
const view = session as unknown as {
|
|
223
|
+
readonly user: SessionUserOf<A>
|
|
224
|
+
readonly session: { readonly id: string }
|
|
225
|
+
}
|
|
226
|
+
const user = view.user
|
|
227
|
+
const userId = (user as { readonly id: string }).id
|
|
228
|
+
const sessionId = view.session.id
|
|
229
|
+
|
|
230
|
+
const resolveTenant =
|
|
231
|
+
options?.tenantOf ??
|
|
232
|
+
((u: SessionUserOf<A>): string | undefined => {
|
|
233
|
+
const record = u as { readonly tenantId?: unknown; readonly orgId?: unknown }
|
|
234
|
+
const value = record.tenantId ?? record.orgId
|
|
235
|
+
return typeof value === "string" ? value : undefined
|
|
236
|
+
})
|
|
237
|
+
// A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
|
|
238
|
+
// returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
|
|
239
|
+
const resolved = resolveTenant(user)
|
|
240
|
+
const tenantId = resolved === undefined || resolved === "" ? undefined : resolved
|
|
241
|
+
|
|
242
|
+
if (options?.requireTenant === true && tenantId === undefined) throw forbidden()
|
|
243
|
+
|
|
244
|
+
// Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
|
|
245
|
+
// single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
|
|
246
|
+
// shape is exactly the mapped session, no untrusted data crosses here.
|
|
247
|
+
const principal: Principal<SessionUserOf<A>> =
|
|
248
|
+
tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId }
|
|
249
|
+
return principal as PrincipalFor<SessionUserOf<A>, RequireTenant>
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
|
|
254
|
+
* `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
|
|
255
|
+
* and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
|
|
256
|
+
* forgotten. Works in both modes:
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* // inline
|
|
260
|
+
* const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
|
|
261
|
+
* // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
|
|
262
|
+
* const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
|
|
263
|
+
* ```
|
|
264
|
+
*
|
|
265
|
+
* With `{ requireTenant: true }`, `c.principal.tenantId` is typed `string` (a missing tenant is a `403`).
|
|
266
|
+
*
|
|
267
|
+
* DESIGN NOTE: this is an **unnamed** plugin by necessity. A named plugin (for idempotent dedupe) carries
|
|
268
|
+
* a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
|
|
269
|
+
* overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
|
|
270
|
+
* Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
|
|
271
|
+
* twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
|
|
272
|
+
*/
|
|
273
|
+
export function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(
|
|
274
|
+
auth: A,
|
|
275
|
+
options?: AuthedOptions<SessionUserOf<A>> & { readonly requireTenant?: RequireTenant },
|
|
276
|
+
): <S extends AnyServer>(
|
|
277
|
+
app: S,
|
|
278
|
+
) => WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>> {
|
|
279
|
+
return <S extends AnyServer>(app: S) =>
|
|
280
|
+
app.derive(async (c: { readonly req: Request }) => ({
|
|
281
|
+
principal: await requirePrincipal(auth, c.req, options),
|
|
282
|
+
})) as unknown as WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>
|
|
283
|
+
}
|