@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 +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +105 -38
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/index.ts +135 -47
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.
|
|
57
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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;
|
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":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
41
|
-
*
|
|
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,
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
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":"AAAA,OAAO,
|
|
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": "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": "^
|
|
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.
|
|
88
|
-
*
|
|
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
|
-
|
|
102
|
+
input: Request | Headers | AuthHeaders,
|
|
98
103
|
): Promise<SessionOf<A> | null> {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
}
|