@nifrajs/better-auth 3.5.0 → 4.0.2

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AnyServer, type Server } from "@nifrajs/core/server";
1
+ import { type AnyServer, type AuthHeaders, type Server } from "@nifrajs/core/server";
2
2
  /**
3
3
  * The structural slice of a [better-auth](https://better-auth.com) instance this package needs.
4
4
  * Declared structurally rather than imported, so `@nifrajs/better-auth` has **no runtime dependency** on
@@ -53,15 +53,15 @@ export interface BetterAuthOptions {
53
53
  export declare function betterAuth(auth: BetterAuthLike, options?: BetterAuthOptions): import("@nifrajs/core").IdentityPlugin<never>;
54
54
  /**
55
55
  * Resolve the better-auth session for a request - a thin, typed wrapper over `auth.api.getSession`.
56
- * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
57
- * (`c.req`) and web loaders/actions (`request`).
56
+ * Returns `null` when unauthenticated. Accepts a raw `Request` for handlers/loaders, native
57
+ * `Headers`, or the body-blind `AuthHeaders` capsule supplied by `authenticate()`.
58
58
  *
59
59
  * ```ts
60
60
  * const session = await getSession(auth, c.req) // typed: { user, session } | null
61
61
  * if (session) c.set.headers["x-user"] = session.user.id
62
62
  * ```
63
63
  */
64
- export declare function getSession<A extends BetterAuthLike>(auth: A, request: Request): Promise<SessionOf<A> | null>;
64
+ export declare function getSession<A extends BetterAuthLike>(auth: A, input: Request | Headers | AuthHeaders): Promise<SessionOf<A> | null>;
65
65
  /**
66
66
  * What {@link requireSession} does on a missing session: `302` to `redirectTo` (a same-origin path),
67
67
  * or - when omitted - a `401` JSON (`{ ok: false, error: "unauthorized" }`). Mirrors `@nifrajs/auth` guards.
@@ -148,7 +148,8 @@ export declare function requirePrincipal<A extends BetterAuthLike, const Require
148
148
  readonly requireTenant?: RequireTenant;
149
149
  }): Promise<PrincipalFor<SessionUserOf<A>, RequireTenant>>;
150
150
  /**
151
- * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
151
+ * A nifra plugin that authenticates the request before untrusted input validation and threads a
152
+ * fail-closed {@link Principal} onto every downstream handler as
152
153
  * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
153
154
  * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
154
155
  * forgotten. Works in both modes:
@@ -156,7 +157,7 @@ export declare function requirePrincipal<A extends BetterAuthLike, const Require
156
157
  * ```ts
157
158
  * // inline
158
159
  * 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
+ * // contract-first (the pre-applied auth stage threads `principal` into the contract's handlers)
160
161
  * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
161
162
  * ```
162
163
  *
@@ -166,7 +167,7 @@ export declare function requirePrincipal<A extends BetterAuthLike, const Require
166
167
  * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
167
168
  * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
168
169
  * 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
+ * twice simply installs two auth stages; scope it once per app so the policy is unambiguous.
170
171
  */
171
172
  export declare function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(auth: A, options?: AuthedOptions<SessionUserOf<A>> & {
172
173
  readonly requireTenant?: RequireTenant;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { defineIdentityPlugin, isSameOriginPath, status, } from "@nifrajs/core/server";
1
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance";
2
+ import { authenticated, defineIdentityPlugin, isSameOriginPath, rejected, status, } from "@nifrajs/core/server";
2
3
  // better-auth dispatches only GET (session, OAuth/email callbacks) and POST (sign-in/up/out, etc.).
3
4
  const HANDLED_METHODS = ["GET", "POST"];
4
5
  /**
@@ -37,31 +38,43 @@ export function betterAuth(auth, options = {}) {
37
38
  }
38
39
  /**
39
40
  * Resolve the better-auth session for a request - a thin, typed wrapper over `auth.api.getSession`.
40
- * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
41
- * (`c.req`) and web loaders/actions (`request`).
41
+ * Returns `null` when unauthenticated. Accepts a raw `Request` for handlers/loaders, native
42
+ * `Headers`, or the body-blind `AuthHeaders` capsule supplied by `authenticate()`.
42
43
  *
43
44
  * ```ts
44
45
  * const session = await getSession(auth, c.req) // typed: { user, session } | null
45
46
  * if (session) c.set.headers["x-user"] = session.user.id
46
47
  * ```
47
48
  */
48
- export function getSession(auth, request) {
49
- // `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
50
- // the cast bridges the erased `Promise<unknown>` view inside this generic body.
51
- return auth.api.getSession({ headers: request.headers });
49
+ export function getSession(auth, input) {
50
+ // Always give the provider a detached Headers object. That keeps the authentication boundary
51
+ // body-blind and prevents a provider from mutating the request's live header view. The
52
+ // AuthenticationInput capsule intentionally is not a Headers instance, so this copy is also the
53
+ // adapter between the narrow core auth API and better-auth's native `{ headers: Headers }` shape.
54
+ const source = "headers" in input ? input.headers : input;
55
+ const headers = new Headers();
56
+ source.forEach((value, name) => {
57
+ headers.append(name, value);
58
+ });
59
+ const result = auth.api.getSession({ headers });
60
+ // Some adapters use `undefined` for an absent session even though better-auth documents `null`.
61
+ // Normalize both forms so every caller has one fail-closed branch.
62
+ return result.then((session) => (session == null ? null : session));
52
63
  }
53
64
  const rejection = (options) => {
54
65
  const to = options.redirectTo;
55
66
  if (to === undefined)
56
67
  return status(401, { ok: false, error: "unauthorized" });
68
+ return status(302, undefined, { headers: { location: to } });
69
+ };
70
+ function assertSafeRedirectTo(to, owner) {
71
+ if (to === undefined || isSameOriginPath(to))
72
+ return;
57
73
  // The kernel's same-origin predicate, shared with `@nifrajs/web`'s `redirect` and the
58
74
  // `@nifrajs/auth` guards: a single leading "/", never "//host", an absolute URL, or a form a URL
59
75
  // parser resolves off-origin. `redirectTo` is dev-authored, so a bad value is a config bug - fail loud.
60
- if (!isSameOriginPath(to)) {
61
- throw new Error(`[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" - never "//", a backslash, or a control character (got ${JSON.stringify(to)})`);
62
- }
63
- return status(302, undefined, { headers: { location: to } });
64
- };
76
+ throw new Error(`[nifra/better-auth] ${owner} redirectTo must be a same-origin path beginning with "/" - never "//", a backslash, or a control character (got ${JSON.stringify(to)})`);
77
+ }
65
78
  /**
66
79
  * Require an authenticated better-auth session at the top of a protected handler/loader/action.
67
80
  * Returns the (non-null) session when present; otherwise **throws a Nifra `ResponseResult`** (302/401).
@@ -72,12 +85,57 @@ const rejection = (options) => {
72
85
  * ```
73
86
  */
74
87
  export async function requireSession(auth, request, options = {}) {
88
+ assertSafeRedirectTo(options.redirectTo, "requireSession");
75
89
  const session = await getSession(auth, request);
76
90
  if (session !== null)
77
91
  return session;
78
92
  throw rejection(options);
79
93
  }
80
94
  const forbidden = () => status(403, { ok: false, error: "forbidden" });
95
+ function recordOf(value) {
96
+ return value !== null && typeof value === "object"
97
+ ? value
98
+ : undefined;
99
+ }
100
+ /**
101
+ * Validate and map a provider session before it becomes trusted handler context. A concrete
102
+ * better-auth type is useful to application code, but it cannot replace these runtime checks: a
103
+ * broken database adapter or a forged test double must never produce a principal with an empty id.
104
+ */
105
+ function principalFromSession(session, options) {
106
+ const sessionRecord = recordOf(session);
107
+ const userRecord = recordOf(sessionRecord?.user);
108
+ const sessionFields = recordOf(sessionRecord?.session);
109
+ const userId = userRecord?.id;
110
+ const sessionId = sessionFields?.id;
111
+ if (userRecord === undefined ||
112
+ typeof userId !== "string" ||
113
+ userId.trim().length === 0 ||
114
+ sessionFields === undefined ||
115
+ typeof sessionId !== "string" ||
116
+ sessionId.trim().length === 0) {
117
+ throw new Error("better-auth returned an invalid session");
118
+ }
119
+ const user = sessionRecord?.user;
120
+ const resolveTenant = options?.tenantOf ??
121
+ ((value) => {
122
+ const record = value;
123
+ const candidate = record.tenantId ?? record.orgId;
124
+ return typeof candidate === "string" ? candidate : undefined;
125
+ });
126
+ const resolved = resolveTenant(user);
127
+ if (resolved !== undefined && typeof resolved !== "string") {
128
+ throw new Error("better-auth returned an invalid tenant");
129
+ }
130
+ const tenantId = resolved === undefined || resolved.trim().length === 0 ? undefined : resolved;
131
+ if (options?.requireTenant === true && tenantId === undefined)
132
+ return { kind: "forbidden" };
133
+ const principal = tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId };
134
+ return {
135
+ kind: "authenticated",
136
+ principal: principal,
137
+ };
138
+ }
81
139
  /**
82
140
  * Resolve the better-auth session and map it to a {@link Principal}, or **throw a Nifra `ResponseResult`** so the
83
141
  * handler never runs unauthenticated:
@@ -98,30 +156,14 @@ export async function requirePrincipal(auth, request, options) {
98
156
  const session = await requireSession(auth, request, options?.redirectTo !== undefined ? { redirectTo: options.redirectTo } : {});
99
157
  // better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
100
158
  // fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
101
- const view = session;
102
- const user = view.user;
103
- const userId = user.id;
104
- const sessionId = view.session.id;
105
- const resolveTenant = options?.tenantOf ??
106
- ((u) => {
107
- const record = u;
108
- const value = record.tenantId ?? record.orgId;
109
- return typeof value === "string" ? value : undefined;
110
- });
111
- // A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
112
- // returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
113
- const resolved = resolveTenant(user);
114
- const tenantId = resolved === undefined || resolved === "" ? undefined : resolved;
115
- if (options?.requireTenant === true && tenantId === undefined)
159
+ const mapped = principalFromSession(session, options);
160
+ if (mapped.kind === "forbidden")
116
161
  throw forbidden();
117
- // Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
118
- // single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
119
- // shape is exactly the mapped session, no untrusted data crosses here.
120
- const principal = tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId };
121
- return principal;
162
+ return mapped.principal;
122
163
  }
123
164
  /**
124
- * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
165
+ * A nifra plugin that authenticates the request before untrusted input validation and threads a
166
+ * fail-closed {@link Principal} onto every downstream handler as
125
167
  * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
126
168
  * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
127
169
  * forgotten. Works in both modes:
@@ -129,7 +171,7 @@ export async function requirePrincipal(auth, request, options) {
129
171
  * ```ts
130
172
  * // inline
131
173
  * const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
132
- * // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
174
+ * // contract-first (the pre-applied auth stage threads `principal` into the contract's handlers)
133
175
  * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
134
176
  * ```
135
177
  *
@@ -139,11 +181,36 @@ export async function requirePrincipal(auth, request, options) {
139
181
  * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
140
182
  * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
141
183
  * Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
142
- * twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
184
+ * twice simply installs two auth stages; scope it once per app so the policy is unambiguous.
143
185
  */
144
186
  export function authed(auth, options) {
145
- return (app) => app.derive(async (c) => ({
146
- principal: await requirePrincipal(auth, c.req, options),
147
- }));
187
+ assertSafeRedirectTo(options?.redirectTo, "authed");
188
+ const plugin = (app) => app.authenticate({
189
+ id: "better-auth",
190
+ mode: "async",
191
+ run: async (input) => {
192
+ try {
193
+ const session = await getSession(auth, input.headers);
194
+ if (session === null) {
195
+ return rejected("unauthenticated", rejection(options?.redirectTo === undefined ? {} : { redirectTo: options.redirectTo }));
196
+ }
197
+ const mapped = principalFromSession(session, options);
198
+ if (mapped.kind === "forbidden")
199
+ return rejected("forbidden");
200
+ return authenticated(mapped.principal);
201
+ }
202
+ catch {
203
+ // Provider failures, malformed rows, and malformed custom tenant resolvers all fail closed
204
+ // without leaking database/auth detail to the client. The core stage turns this into a
205
+ // stable 503, while 401/403 remain reserved for the two intentional policy decisions.
206
+ return rejected("unavailable");
207
+ }
208
+ },
209
+ });
210
+ return withRouteAssurance(plugin, {
211
+ id: NIFRA_ASSURANCE.AUTHENTICATED,
212
+ source: "better-auth",
213
+ scope: "subsequent",
214
+ });
148
215
  }
149
216
  //# 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":"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"}
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.5.0",
3
+ "version": "4.0.2",
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",
@@ -17,6 +17,9 @@
17
17
  "better-auth"
18
18
  ],
19
19
  "type": "module",
20
+ "nifra": {
21
+ "environment": "library"
22
+ },
20
23
  "main": "./dist/index.js",
21
24
  "module": "./dist/index.js",
22
25
  "types": "./dist/index.d.ts",
@@ -40,7 +43,7 @@
40
43
  "access": "public"
41
44
  },
42
45
  "peerDependencies": {
43
- "@nifrajs/core": "^3.5.0",
46
+ "@nifrajs/core": "^4.0.2",
44
47
  "better-auth": ">=1.0.0"
45
48
  },
46
49
  "peerDependenciesMeta": {
package/src/index.ts CHANGED
@@ -1,8 +1,13 @@
1
+ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
1
2
  import {
2
3
  type AnyServer,
4
+ type AuthenticationInput,
5
+ type AuthHeaders,
6
+ authenticated,
3
7
  defineIdentityPlugin,
4
8
  isSameOriginPath,
5
9
  type ResponseResult,
10
+ rejected,
6
11
  type Server,
7
12
  status,
8
13
  } from "@nifrajs/core/server"
@@ -84,8 +89,8 @@ export function betterAuth(auth: BetterAuthLike, options: BetterAuthOptions = {}
84
89
 
85
90
  /**
86
91
  * Resolve the better-auth session for a request - a thin, typed wrapper over `auth.api.getSession`.
87
- * Returns `null` when unauthenticated. Takes the raw `Request` so it works in both server handlers
88
- * (`c.req`) and web loaders/actions (`request`).
92
+ * Returns `null` when unauthenticated. Accepts a raw `Request` for handlers/loaders, native
93
+ * `Headers`, or the body-blind `AuthHeaders` capsule supplied by `authenticate()`.
89
94
  *
90
95
  * ```ts
91
96
  * const session = await getSession(auth, c.req) // typed: { user, session } | null
@@ -94,11 +99,21 @@ export function betterAuth(auth: BetterAuthLike, options: BetterAuthOptions = {}
94
99
  */
95
100
  export function getSession<A extends BetterAuthLike>(
96
101
  auth: A,
97
- request: Request,
102
+ input: Request | Headers | AuthHeaders,
98
103
  ): Promise<SessionOf<A> | null> {
99
- // `auth` is the concrete `A`, so `getSession`'s real return type is recovered by `SessionOf<A>`;
100
- // the cast bridges the erased `Promise<unknown>` view inside this generic body.
101
- return auth.api.getSession({ headers: request.headers }) as Promise<SessionOf<A> | null>
104
+ // Always give the provider a detached Headers object. That keeps the authentication boundary
105
+ // body-blind and prevents a provider from mutating the request's live header view. The
106
+ // AuthenticationInput capsule intentionally is not a Headers instance, so this copy is also the
107
+ // adapter between the narrow core auth API and better-auth's native `{ headers: Headers }` shape.
108
+ const source = "headers" in input ? input.headers : input
109
+ const headers = new Headers()
110
+ source.forEach((value, name) => {
111
+ headers.append(name, value)
112
+ })
113
+ const result = auth.api.getSession({ headers }) as Promise<SessionOf<A> | null | undefined>
114
+ // Some adapters use `undefined` for an absent session even though better-auth documents `null`.
115
+ // Normalize both forms so every caller has one fail-closed branch.
116
+ return result.then((session) => (session == null ? null : session))
102
117
  }
103
118
 
104
119
  /**
@@ -112,15 +127,17 @@ export interface RequireSessionOptions {
112
127
  const rejection = (options: RequireSessionOptions): ResponseResult => {
113
128
  const to = options.redirectTo
114
129
  if (to === undefined) return status(401, { ok: false, error: "unauthorized" })
130
+ return status(302, undefined, { headers: { location: to } })
131
+ }
132
+
133
+ function assertSafeRedirectTo(to: string | undefined, owner: "requireSession" | "authed"): void {
134
+ if (to === undefined || isSameOriginPath(to)) return
115
135
  // The kernel's same-origin predicate, shared with `@nifrajs/web`'s `redirect` and the
116
136
  // `@nifrajs/auth` guards: a single leading "/", never "//host", an absolute URL, or a form a URL
117
137
  // parser resolves off-origin. `redirectTo` is dev-authored, so a bad value is a config bug - fail loud.
118
- if (!isSameOriginPath(to)) {
119
- throw new Error(
120
- `[nifra/better-auth] requireSession redirectTo must be a same-origin path beginning with "/" - never "//", a backslash, or a control character (got ${JSON.stringify(to)})`,
121
- )
122
- }
123
- return status(302, undefined, { headers: { location: to } })
138
+ throw new Error(
139
+ `[nifra/better-auth] ${owner} redirectTo must be a same-origin path beginning with "/" - never "//", a backslash, or a control character (got ${JSON.stringify(to)})`,
140
+ )
124
141
  }
125
142
 
126
143
  /**
@@ -137,6 +154,7 @@ export async function requireSession<A extends BetterAuthLike>(
137
154
  request: Request,
138
155
  options: RequireSessionOptions = {},
139
156
  ): Promise<SessionOf<A>> {
157
+ assertSafeRedirectTo(options.redirectTo, "requireSession")
140
158
  const session = await getSession(auth, request)
141
159
  if (session !== null) return session
142
160
  throw rejection(options)
@@ -197,6 +215,72 @@ export type WithPrincipal<S extends AnyServer, P> =
197
215
 
198
216
  const forbidden = (): ResponseResult => status(403, { ok: false, error: "forbidden" })
199
217
 
218
+ type PrincipalMapping<A extends BetterAuthLike, RequireTenant extends boolean> =
219
+ | {
220
+ readonly kind: "authenticated"
221
+ readonly principal: PrincipalFor<SessionUserOf<A>, RequireTenant>
222
+ }
223
+ | { readonly kind: "forbidden" }
224
+
225
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
226
+ return value !== null && typeof value === "object"
227
+ ? (value as Record<string, unknown>)
228
+ : undefined
229
+ }
230
+
231
+ /**
232
+ * Validate and map a provider session before it becomes trusted handler context. A concrete
233
+ * better-auth type is useful to application code, but it cannot replace these runtime checks: a
234
+ * broken database adapter or a forged test double must never produce a principal with an empty id.
235
+ */
236
+ function principalFromSession<
237
+ A extends BetterAuthLike,
238
+ const RequireTenant extends boolean = false,
239
+ >(
240
+ session: SessionOf<A>,
241
+ options?: AuthedOptions<SessionUserOf<A>> & { readonly requireTenant?: RequireTenant },
242
+ ): PrincipalMapping<A, RequireTenant> {
243
+ const sessionRecord = recordOf(session)
244
+ const userRecord = recordOf(sessionRecord?.user)
245
+ const sessionFields = recordOf(sessionRecord?.session)
246
+ const userId = userRecord?.id
247
+ const sessionId = sessionFields?.id
248
+ if (
249
+ userRecord === undefined ||
250
+ typeof userId !== "string" ||
251
+ userId.trim().length === 0 ||
252
+ sessionFields === undefined ||
253
+ typeof sessionId !== "string" ||
254
+ sessionId.trim().length === 0
255
+ ) {
256
+ throw new Error("better-auth returned an invalid session")
257
+ }
258
+
259
+ const user = sessionRecord?.user as SessionUserOf<A>
260
+ const resolveTenant =
261
+ options?.tenantOf ??
262
+ ((value: SessionUserOf<A>): string | undefined => {
263
+ const record = value as { readonly tenantId?: unknown; readonly orgId?: unknown }
264
+ const candidate = record.tenantId ?? record.orgId
265
+ return typeof candidate === "string" ? candidate : undefined
266
+ })
267
+ const resolved = resolveTenant(user) as unknown
268
+ if (resolved !== undefined && typeof resolved !== "string") {
269
+ throw new Error("better-auth returned an invalid tenant")
270
+ }
271
+ const tenantId =
272
+ resolved === undefined || resolved.trim().length === 0 ? undefined : (resolved as string)
273
+
274
+ if (options?.requireTenant === true && tenantId === undefined) return { kind: "forbidden" }
275
+
276
+ const principal: Principal<SessionUserOf<A>> =
277
+ tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId }
278
+ return {
279
+ kind: "authenticated",
280
+ principal: principal as PrincipalFor<SessionUserOf<A>, RequireTenant>,
281
+ }
282
+ }
283
+
200
284
  /**
201
285
  * Resolve the better-auth session and map it to a {@link Principal}, or **throw a Nifra `ResponseResult`** so the
202
286
  * handler never runs unauthenticated:
@@ -228,38 +312,14 @@ export async function requirePrincipal<
228
312
  )
229
313
  // better-auth sessions are `{ user: { id: string, ... }, session: { id: string, ... } }`; view the
230
314
  // fields we map. The concrete user type flows through `SessionUserOf<A>` for `principal.user`.
231
- const view = session as unknown as {
232
- readonly user: SessionUserOf<A>
233
- readonly session: { readonly id: string }
234
- }
235
- const user = view.user
236
- const userId = (user as { readonly id: string }).id
237
- const sessionId = view.session.id
238
-
239
- const resolveTenant =
240
- options?.tenantOf ??
241
- ((u: SessionUserOf<A>): string | undefined => {
242
- const record = u as { readonly tenantId?: unknown; readonly orgId?: unknown }
243
- const value = record.tenantId ?? record.orgId
244
- return typeof value === "string" ? value : undefined
245
- })
246
- // A blank tenant is NOT a resolved tenant: a NOT-NULL column defaulted to "" (or a custom `tenantOf`
247
- // returning "") must fail closed under `requireTenant`, never bind the principal to an empty tenant.
248
- const resolved = resolveTenant(user)
249
- const tenantId = resolved === undefined || resolved === "" ? undefined : resolved
250
-
251
- if (options?.requireTenant === true && tenantId === undefined) throw forbidden()
252
-
253
- // Build the principal without ever assigning `tenantId: undefined` (exactOptionalPropertyTypes). The
254
- // single cast maps our own constructed object onto the conditional `PrincipalFor` return - the runtime
255
- // shape is exactly the mapped session, no untrusted data crosses here.
256
- const principal: Principal<SessionUserOf<A>> =
257
- tenantId === undefined ? { user, userId, sessionId } : { user, userId, sessionId, tenantId }
258
- return principal as PrincipalFor<SessionUserOf<A>, RequireTenant>
315
+ const mapped = principalFromSession(session, options)
316
+ if (mapped.kind === "forbidden") throw forbidden()
317
+ return mapped.principal
259
318
  }
260
319
 
261
320
  /**
262
- * A nifra plugin that derives a fail-closed {@link Principal} onto every downstream handler as
321
+ * A nifra plugin that authenticates the request before untrusted input validation and threads a
322
+ * fail-closed {@link Principal} onto every downstream handler as
263
323
  * `c.principal`. After `server().use(authed(auth))`, `c.principal.user` / `c.principal.userId` are typed
264
324
  * and **non-null** - a handler CANNOT run without an authenticated caller, so the guard can't be
265
325
  * forgotten. Works in both modes:
@@ -267,7 +327,7 @@ export async function requirePrincipal<
267
327
  * ```ts
268
328
  * // inline
269
329
  * const app = server().use(authed(auth)).get("/me", (c) => ({ id: c.principal.userId }))
270
- * // contract-first (the pre-applied derive threads `principal` into the contract's handlers)
330
+ * // contract-first (the pre-applied auth stage threads `principal` into the contract's handlers)
271
331
  * const api = implement(contract, handlers, server().use(authed(auth, { requireTenant: true })))
272
332
  * ```
273
333
  *
@@ -277,7 +337,7 @@ export async function requirePrincipal<
277
337
  * a `& { pluginName }` intersection that defeats the generic inference of `use`'s context-threading
278
338
  * overload and collapses the server - and its typed client - to `any` (see `@nifrajs/core` plugin docs).
279
339
  * Threading a NON-NULL principal is the whole point, so `authed` stays unnamed and generic. Applying it
280
- * twice simply derives twice (the second resolve overwrites with the same value); scope it once per app.
340
+ * twice simply installs two auth stages; scope it once per app so the policy is unambiguous.
281
341
  */
282
342
  export function authed<A extends BetterAuthLike, const RequireTenant extends boolean = false>(
283
343
  auth: A,
@@ -285,8 +345,36 @@ export function authed<A extends BetterAuthLike, const RequireTenant extends boo
285
345
  ): <S extends AnyServer>(
286
346
  app: S,
287
347
  ) => WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>> {
288
- return <S extends AnyServer>(app: S) =>
289
- app.derive(async (c: { readonly req: Request }) => ({
290
- principal: await requirePrincipal(auth, c.req, options),
291
- })) as unknown as WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>
348
+ assertSafeRedirectTo(options?.redirectTo, "authed")
349
+ const plugin = <S extends AnyServer>(app: S) =>
350
+ app.authenticate({
351
+ id: "better-auth",
352
+ mode: "async",
353
+ run: async (input: AuthenticationInput<unknown>) => {
354
+ try {
355
+ const session = await getSession(auth, input.headers)
356
+ if (session === null) {
357
+ return rejected(
358
+ "unauthenticated",
359
+ rejection(
360
+ options?.redirectTo === undefined ? {} : { redirectTo: options.redirectTo },
361
+ ),
362
+ )
363
+ }
364
+ const mapped = principalFromSession(session, options)
365
+ if (mapped.kind === "forbidden") return rejected("forbidden")
366
+ return authenticated(mapped.principal)
367
+ } catch {
368
+ // Provider failures, malformed rows, and malformed custom tenant resolvers all fail closed
369
+ // without leaking database/auth detail to the client. The core stage turns this into a
370
+ // stable 503, while 401/403 remain reserved for the two intentional policy decisions.
371
+ return rejected("unavailable")
372
+ }
373
+ },
374
+ }) as unknown as WithPrincipal<S, PrincipalFor<SessionUserOf<A>, RequireTenant>>
375
+ return withRouteAssurance(plugin, {
376
+ id: NIFRA_ASSURANCE.AUTHENTICATED,
377
+ source: "better-auth",
378
+ scope: "subsequent",
379
+ })
292
380
  }