@protobase/server 0.28.4 → 0.29.1
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/auth/jwt.js +16 -8
- package/dist/better-auth/authenticator.d.ts +4 -1
- package/dist/better-auth/authenticator.js +13 -3
- package/dist/better-auth/create-auth.d.ts +4 -0
- package/dist/better-auth/create-auth.js +1 -0
- package/dist/better-auth/token-standing.js +55 -0
- package/dist/create-admin.js +1 -0
- package/dist/functions/define-function.d.ts +8 -1
- package/dist/functions/function-gate.js +36 -0
- package/dist/functions/routes.js +6 -8
- package/package.json +4 -4
package/dist/auth/jwt.js
CHANGED
|
@@ -32,8 +32,8 @@ const jwtVerifier = (options) => {
|
|
|
32
32
|
})).payload;
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
|
-
/**
|
|
36
|
-
const
|
|
35
|
+
/** Like `jwtAuthenticator`, and also returns the verified claims, for an authenticator that checks more of them. */
|
|
36
|
+
const jwtSessions = (options) => {
|
|
37
37
|
const { rolesClaim = "roles", tenantClaim = "tenant" } = options;
|
|
38
38
|
const verify = jwtVerifier(options);
|
|
39
39
|
return async (request) => {
|
|
@@ -48,13 +48,21 @@ const jwtAuthenticator = (options) => {
|
|
|
48
48
|
const claimed = typeof tenant === "string" || typeof tenant === "number" ? tenant : void 0;
|
|
49
49
|
const tenantValue = options.tenant === void 0 ? claimed : typeof options.tenant === "function" ? await options.tenant() : options.tenant;
|
|
50
50
|
return {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
payload,
|
|
52
|
+
session: {
|
|
53
|
+
user: {
|
|
54
|
+
id: sub,
|
|
55
|
+
roles: [.../* @__PURE__ */ new Set([...rolesOf(roles), ...extraRoles])]
|
|
56
|
+
},
|
|
57
|
+
...tenantValue !== void 0 && { tenant: tenantValue }
|
|
58
|
+
}
|
|
56
59
|
};
|
|
57
60
|
};
|
|
58
61
|
};
|
|
62
|
+
/** Verifies `Authorization: Bearer <jwt>` against a JWKS; `sub` is the user id. Without the header, `MissingCredentials`. */
|
|
63
|
+
const jwtAuthenticator = (options) => {
|
|
64
|
+
const sessions = jwtSessions(options);
|
|
65
|
+
return async (request) => (await sessions(request)).session;
|
|
66
|
+
};
|
|
59
67
|
//#endregion
|
|
60
|
-
export { jwtAuthenticator, jwtVerifier };
|
|
68
|
+
export { jwtAuthenticator, jwtSessions, jwtVerifier };
|
|
@@ -28,7 +28,10 @@ export declare const betterAuthTokens: (options: Pick<BetterAuthAuthenticatorOpt
|
|
|
28
28
|
* Verifies the bearer JWT that Better Auth's jwt plugin issues. `sub` is the user id, the `role` claim (a comma
|
|
29
29
|
* separated string from the admin plugin) becomes `roles`. With organizations, the `org` claim is the tenant and the
|
|
30
30
|
* organization, and the membership's `app_roles` are added to the global roles; `user`, which stands for no global
|
|
31
|
-
* role, is left out. With `
|
|
31
|
+
* role, is left out. With `auth`, every request also reads the store to check that the token still stands
|
|
32
|
+
* (`checkTokenStanding`), so a ban, a lowered role, a deleted user or a sign-out ends it at once; with `jwksUrl` alone
|
|
33
|
+
* a token works until it expires. With `apps`, the tokens of those apps are admitted too, with the roles each gives
|
|
34
|
+
* its users here.
|
|
32
35
|
*/
|
|
33
36
|
export declare const betterAuthAuthenticator: (options: BetterAuthAuthenticatorOptions) => Authenticator;
|
|
34
37
|
//#endregion
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jwtSessions } from "../auth/jwt.js";
|
|
2
2
|
import { noGlobalRole } from "./organizations/role-definitions.js";
|
|
3
3
|
import { cachedKeys } from "./jwks-cache.js";
|
|
4
4
|
import { withTrustedApps } from "./app-tokens.js";
|
|
5
|
+
import { checkTokenStanding, tokenClaims } from "./token-standing.js";
|
|
5
6
|
//#region src/better-auth/authenticator.ts
|
|
6
7
|
/** How the tokens of this Better Auth instance verify: its key set, and its issuer as both issuer and audience. */
|
|
7
8
|
const betterAuthTokens = (options) => {
|
|
@@ -18,13 +19,16 @@ const betterAuthTokens = (options) => {
|
|
|
18
19
|
* Verifies the bearer JWT that Better Auth's jwt plugin issues. `sub` is the user id, the `role` claim (a comma
|
|
19
20
|
* separated string from the admin plugin) becomes `roles`. With organizations, the `org` claim is the tenant and the
|
|
20
21
|
* organization, and the membership's `app_roles` are added to the global roles; `user`, which stands for no global
|
|
21
|
-
* role, is left out. With `
|
|
22
|
+
* role, is left out. With `auth`, every request also reads the store to check that the token still stands
|
|
23
|
+
* (`checkTokenStanding`), so a ban, a lowered role, a deleted user or a sign-out ends it at once; with `jwksUrl` alone
|
|
24
|
+
* a token works until it expires. With `apps`, the tokens of those apps are admitted too, with the roles each gives
|
|
25
|
+
* its users here.
|
|
22
26
|
*/
|
|
23
27
|
const betterAuthAuthenticator = (options) => {
|
|
24
28
|
const tokens = betterAuthTokens(options);
|
|
25
29
|
const { issuer } = tokens;
|
|
26
30
|
const organizations = options.organizations ?? Boolean(options.auth?.organizations);
|
|
27
|
-
const
|
|
31
|
+
const sessions = jwtSessions({
|
|
28
32
|
...tokens,
|
|
29
33
|
rolesClaim: "role",
|
|
30
34
|
...organizations && {
|
|
@@ -33,6 +37,12 @@ const betterAuthAuthenticator = (options) => {
|
|
|
33
37
|
},
|
|
34
38
|
...options.tenant !== void 0 && { tenant: options.tenant }
|
|
35
39
|
});
|
|
40
|
+
const { auth } = options;
|
|
41
|
+
const verify = async (request) => {
|
|
42
|
+
const { payload, session } = await sessions(request);
|
|
43
|
+
if (auth) await checkTokenStanding(await auth.$context, tokenClaims(payload, session.user.roles, organizations ? session.tenant : void 0), organizations);
|
|
44
|
+
return session;
|
|
45
|
+
};
|
|
36
46
|
return withTrustedApps(!organizations ? verify : async (request) => {
|
|
37
47
|
const session = await verify(request);
|
|
38
48
|
const user = {
|
|
@@ -1165,24 +1165,28 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
1165
1165
|
}) => Promise<{
|
|
1166
1166
|
role: any;
|
|
1167
1167
|
email: string;
|
|
1168
|
+
sid: string;
|
|
1168
1169
|
} | {
|
|
1169
1170
|
org?: undefined;
|
|
1170
1171
|
org_role?: undefined;
|
|
1171
1172
|
app_roles?: undefined;
|
|
1172
1173
|
role: any;
|
|
1173
1174
|
email: string;
|
|
1175
|
+
sid: string;
|
|
1174
1176
|
} | {
|
|
1175
1177
|
org: string;
|
|
1176
1178
|
org_role: OrganizationRole;
|
|
1177
1179
|
app_roles: string[];
|
|
1178
1180
|
role: any;
|
|
1179
1181
|
email: string;
|
|
1182
|
+
sid: string;
|
|
1180
1183
|
} | {
|
|
1181
1184
|
org: string;
|
|
1182
1185
|
org_role?: undefined;
|
|
1183
1186
|
app_roles?: undefined;
|
|
1184
1187
|
role: any;
|
|
1185
1188
|
email: string;
|
|
1189
|
+
sid: string;
|
|
1186
1190
|
}>;
|
|
1187
1191
|
};
|
|
1188
1192
|
disableSettingJwtHeader: true;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { unauthorized } from "../problem.js";
|
|
2
|
+
import { noGlobalRole } from "./organizations/role-definitions.js";
|
|
3
|
+
import { parseRoles } from "./parse-roles.js";
|
|
4
|
+
import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
|
|
5
|
+
import { findMember, findOrganization } from "./organizations/store.js";
|
|
6
|
+
//#region src/better-auth/token-standing.ts
|
|
7
|
+
const tokenClaims = (payload, roles, tenant) => ({
|
|
8
|
+
userId: payload.sub,
|
|
9
|
+
...typeof payload.sid === "string" && { sessionId: payload.sid },
|
|
10
|
+
roles: roles.filter((role) => role !== noGlobalRole),
|
|
11
|
+
...typeof tenant === "string" && { organizationId: tenant }
|
|
12
|
+
});
|
|
13
|
+
const revoked = () => unauthorized("The bearer token was revoked: get a new one");
|
|
14
|
+
const bannedNow = (user, now) => Boolean(user.banned) && (!user.banExpires || user.banExpires > now);
|
|
15
|
+
const heldWhere = async ({ adapter }, user, organizations, organizationId) => {
|
|
16
|
+
if (!organizations) return parseRoles(user.role);
|
|
17
|
+
const global = globalRoles(user.role);
|
|
18
|
+
if (!organizationId) return global;
|
|
19
|
+
const member = await findMember(adapter, {
|
|
20
|
+
organizationId,
|
|
21
|
+
userId: user.id
|
|
22
|
+
});
|
|
23
|
+
if (member) return effectiveRoles(global, member.appRoles);
|
|
24
|
+
return global.length > 0 && await findOrganization(adapter, organizationId) !== null ? global : void 0;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether a token still stands, read from the store on every request: its user exists and is not banned, its session
|
|
28
|
+
* is not signed out or expired, and each role it carries is one the user still holds there. A token that fails is a
|
|
29
|
+
* 401, so the client asks its session for a new one, which carries the roles as they are now or is refused.
|
|
30
|
+
*/
|
|
31
|
+
const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
32
|
+
const { adapter } = context;
|
|
33
|
+
if (claims.sessionId !== void 0) {
|
|
34
|
+
const session = await adapter.findOne({
|
|
35
|
+
model: "session",
|
|
36
|
+
where: [{
|
|
37
|
+
field: "id",
|
|
38
|
+
value: claims.sessionId
|
|
39
|
+
}]
|
|
40
|
+
});
|
|
41
|
+
if (!session || session.userId !== claims.userId || session.expiresAt <= now) throw revoked();
|
|
42
|
+
}
|
|
43
|
+
const user = await adapter.findOne({
|
|
44
|
+
model: "user",
|
|
45
|
+
where: [{
|
|
46
|
+
field: "id",
|
|
47
|
+
value: claims.userId
|
|
48
|
+
}]
|
|
49
|
+
});
|
|
50
|
+
if (!user || bannedNow(user, now)) throw revoked();
|
|
51
|
+
const held = await heldWhere(context, user, organizations, claims.organizationId);
|
|
52
|
+
if (!held || claims.roles.some((role) => !held.includes(role))) throw revoked();
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { checkTokenStanding, tokenClaims };
|
package/dist/create-admin.js
CHANGED
|
@@ -270,6 +270,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
270
270
|
basePath: `${systemPath}/functions`,
|
|
271
271
|
functions: apiFunctions,
|
|
272
272
|
authenticate,
|
|
273
|
+
access: deps.accessOptions,
|
|
273
274
|
db,
|
|
274
275
|
records: (session) => callerRecords(deps, session),
|
|
275
276
|
onError,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Session } from "../types.js";
|
|
2
2
|
import { FunctionRecords } from "./records.js";
|
|
3
|
+
import { Capability } from "@protobase/schema";
|
|
3
4
|
import { Db } from "@protobase/query";
|
|
4
5
|
//#region src/functions/define-function.d.ts
|
|
5
6
|
/** What every function gets: its name and the raw database. */
|
|
@@ -47,6 +48,11 @@ export type ProtectedFunctionOptions = SharedOptions & {
|
|
|
47
48
|
public?: false;
|
|
48
49
|
/** The caller needs one of these roles, otherwise the call is a 403 problem. */
|
|
49
50
|
roles?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* The caller's roles must grant all of this capability (`roles.holds`), such as `vms.update`, otherwise the call is a
|
|
53
|
+
* 403 problem. Needs the app's `roles`; not together with `roles` here.
|
|
54
|
+
*/
|
|
55
|
+
capability?: Capability;
|
|
50
56
|
};
|
|
51
57
|
export type FunctionOptions = PublicFunctionOptions | ProtectedFunctionOptions;
|
|
52
58
|
/** A function as `createAdmin` serves it: its options and what answers its requests. */
|
|
@@ -63,7 +69,8 @@ export type ApiFunction = {
|
|
|
63
69
|
export type FunctionSource = ApiFunction | FunctionHandler | FunctionApp;
|
|
64
70
|
/**
|
|
65
71
|
* A function of your own, served at `/api/functions/<name>`. Without options it needs a signed-in caller, through the
|
|
66
|
-
* app's `authenticate
|
|
72
|
+
* app's `authenticate`, and with the app's `roles` one whose roles hold `*`; `{ roles }` or `{ capability }` opens it
|
|
73
|
+
* to more, `{ public: true }` to anyone, as for a webhook.
|
|
67
74
|
*/
|
|
68
75
|
export declare function defineFunction(handler: FunctionHandler | FunctionApp): ApiFunction;
|
|
69
76
|
export declare function defineFunction(options: PublicFunctionOptions, handler: PublicFunctionHandler | FunctionApp<PublicFunctionContext>): ApiFunction;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { forbidden } from "../problem.js";
|
|
2
|
+
import { parseCapability } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/functions/function-gate.ts
|
|
4
|
+
const open = () => true;
|
|
5
|
+
const holdsOf = (name, access) => {
|
|
6
|
+
const holds = access.roles?.holds;
|
|
7
|
+
if (!holds) throw new Error(`The function "${name}" is gated by the app's roles, but they have no holds(user, capability): define them with defineRoles`);
|
|
8
|
+
return holds;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Who may call a function, checked at startup: anyone, for a public one; one of its `roles`, or a caller holding all of its
|
|
12
|
+
* `capability`. Without either, an app with roles lets in only callers holding `*`, unless `defaultAccess` is `allow`;
|
|
13
|
+
* an app without roles lets in every signed-in caller.
|
|
14
|
+
*/
|
|
15
|
+
const functionGate = (name, options, access) => {
|
|
16
|
+
if (options.public) return open;
|
|
17
|
+
const { roles, capability } = options;
|
|
18
|
+
if (roles && capability) throw new Error(`The function "${name}" has both roles and a capability: give one`);
|
|
19
|
+
if (roles) return (session) => roles.some((role) => session.user.roles.includes(role));
|
|
20
|
+
if (capability) {
|
|
21
|
+
if (!access.roles) throw new Error(`The function "${name}" needs the capability "${capability}", but the app defines no roles`);
|
|
22
|
+
parseCapability(capability);
|
|
23
|
+
const holds = holdsOf(name, access);
|
|
24
|
+
return (session) => holds(session.user, capability);
|
|
25
|
+
}
|
|
26
|
+
if (!access.roles || access.defaultAccess === "allow") return open;
|
|
27
|
+
const holds = holdsOf(name, access);
|
|
28
|
+
return (session) => holds(session.user, "*");
|
|
29
|
+
};
|
|
30
|
+
/** Throws the 403 problem for a caller the gate refuses. */
|
|
31
|
+
const requireGate = (name, gate, session) => {
|
|
32
|
+
if (gate(session)) return;
|
|
33
|
+
throw forbidden("function-forbidden", `You may not call the function "${name}"`);
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { functionGate, requireGate };
|
package/dist/functions/routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { notFound } from "../problem.js";
|
|
2
2
|
import { invokeFunction } from "./define-function.js";
|
|
3
3
|
import { runInBackground } from "../background-work.js";
|
|
4
|
+
import { functionGate, requireGate } from "./function-gate.js";
|
|
4
5
|
import { Hono } from "hono";
|
|
5
6
|
import { cors } from "hono/cors";
|
|
6
7
|
//#region src/functions/routes.ts
|
|
@@ -9,17 +10,13 @@ const relativeRequest = (request, prefix) => {
|
|
|
9
10
|
url.pathname = url.pathname.slice(prefix.length) || "/";
|
|
10
11
|
return new Request(url, request);
|
|
11
12
|
};
|
|
12
|
-
const requireRoles = (name, session, roles) => {
|
|
13
|
-
if (!roles || roles.some((role) => session.user.roles.includes(role))) return;
|
|
14
|
-
throw forbidden("function-forbidden", `You may not call the function "${name}"`);
|
|
15
|
-
};
|
|
16
13
|
/**
|
|
17
14
|
* Every function at `<basePath>/<name>` and below, any method. A protected function authenticates the request first, so
|
|
18
|
-
* a call without a valid token is the authenticator's 401 problem; a public one gets the caller only when it asks. Each
|
|
15
|
+
* a call without a valid token is the authenticator's 401 problem, and one its gate refuses a 403; a public one gets the caller only when it asks. Each
|
|
19
16
|
* call runs as background work, so calls that hold the database for long leave a connection to page requests. Once a
|
|
20
17
|
* call ends, failed or not, `wake` runs, so what it wrote reaches open pages without waiting for the dispatcher's poll.
|
|
21
18
|
*/
|
|
22
|
-
const functionRoutes = ({ basePath, functions, authenticate, db, records, onError, wake }) => {
|
|
19
|
+
const functionRoutes = ({ basePath, functions, authenticate, access, db, records, onError, wake }) => {
|
|
23
20
|
const app = new Hono().basePath(basePath);
|
|
24
21
|
app.onError(onError);
|
|
25
22
|
const callerOf = async (request) => {
|
|
@@ -33,6 +30,7 @@ const functionRoutes = ({ basePath, functions, authenticate, db, records, onErro
|
|
|
33
30
|
for (const [name, fn] of Object.entries(functions)) {
|
|
34
31
|
const prefix = `${basePath}/${name}`;
|
|
35
32
|
const { options } = fn;
|
|
33
|
+
const gate = functionGate(name, options, access);
|
|
36
34
|
if (options.cors) app.use(`/${name}/*`, cors(options.cors));
|
|
37
35
|
app.all(`/${name}/*`, (c) => runInBackground(() => call(async () => {
|
|
38
36
|
const request = relativeRequest(c.req.raw, prefix);
|
|
@@ -45,7 +43,7 @@ const functionRoutes = ({ basePath, functions, authenticate, db, records, onErro
|
|
|
45
43
|
});
|
|
46
44
|
}
|
|
47
45
|
const caller = await callerOf(c.req.raw);
|
|
48
|
-
|
|
46
|
+
requireGate(name, gate, caller.session);
|
|
49
47
|
const context = {
|
|
50
48
|
name,
|
|
51
49
|
db,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.
|
|
36
|
-
"@protobase/query": "0.
|
|
37
|
-
"@protobase/schema": "0.
|
|
35
|
+
"@protobase/layout": "0.29.1",
|
|
36
|
+
"@protobase/query": "0.29.1",
|
|
37
|
+
"@protobase/schema": "0.29.1",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|