@protobase/server 0.49.0 → 0.49.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.
@@ -5,6 +5,7 @@ import { StaffSignIn } from "./staff-log.js";
5
5
  import { RoleDefinitions } from "./organizations/role-definitions.js";
6
6
  import { ConnectedApp } from "./mcp/connected-apps.js";
7
7
  import { ReplacingContext } from "./end-replaced-session.js";
8
+ import { VersionedSecret } from "./secret-versions.js";
8
9
  import { UserDeletionListener } from "./user-deletion.js";
9
10
  import { OrganizationRole, StoredMember, StoredOrganization } from "./organizations/store.js";
10
11
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
@@ -1090,6 +1091,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
1090
1091
  };
1091
1092
  };
1092
1093
  jwks: {
1094
+ rotationInterval: number;
1095
+ gracePeriod: number;
1093
1096
  keyPairConfigs: {
1094
1097
  readonly alg: "ES256";
1095
1098
  }[];
@@ -1352,6 +1355,21 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
1352
1355
  };
1353
1356
  };
1354
1357
  };
1358
+ } | {
1359
+ id: "protobase-signing-keys";
1360
+ endpoints: {
1361
+ ensureSigningKey: import("better-call").StrictEndpoint<string, {
1362
+ method: "POST";
1363
+ }, {
1364
+ ok: boolean;
1365
+ }>;
1366
+ };
1367
+ hooks: {
1368
+ before: {
1369
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
1370
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
1371
+ }[];
1372
+ };
1355
1373
  } | {
1356
1374
  hooks: {
1357
1375
  after: {
@@ -8683,6 +8701,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8683
8701
  };
8684
8702
  basePath: string;
8685
8703
  secret: string;
8704
+ secrets: VersionedSecret[];
8686
8705
  trustedOrigins: string[] | ((request?: Request) => string[]);
8687
8706
  emailAndPassword: {
8688
8707
  onPasswordReset?: ((data: {
@@ -5,6 +5,7 @@ import { StaffSignIn } from "./staff-log.js";
5
5
  import { RoleDefinitions, RolesInput } from "./organizations/role-definitions.js";
6
6
  import { ConnectedApp } from "./mcp/connected-apps.js";
7
7
  import { ReplacingContext } from "./end-replaced-session.js";
8
+ import { VersionedSecret } from "./secret-versions.js";
8
9
  import { UserDeletionListener } from "./user-deletion.js";
9
10
  import { OrganizationRole } from "./organizations/store.js";
10
11
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
@@ -48,6 +49,15 @@ export type CreateAuthOptions = {
48
49
  appName?: string;
49
50
  /** Signs cookies and encrypts the JWT private keys. Generate with `openssl rand -base64 32`. */
50
51
  secret: string;
52
+ /**
53
+ * The secrets the app had before `secret`, newest first, such as `PROTOBASE_PREVIOUS_SECRETS`: what they encrypted
54
+ * still decrypts, so the signing keys keep signing after the secret changes. Default none.
55
+ */
56
+ previousSecrets?: string[];
57
+ /** Days a token signing key signs before a new one takes over; default 30. */
58
+ signingKeyDays?: number;
59
+ /** Days a replaced signing key still verifies the tokens it signed; default 7, at least 1. */
60
+ signingKeyGraceDays?: number;
51
61
  /** Extra origins that may call the auth endpoints, such as the exact host of a dev tunnel. `baseURL` and `origins` are always trusted. */
52
62
  trustedOrigins?: string[];
53
63
  /**
@@ -1200,6 +1210,8 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1200
1210
  };
1201
1211
  };
1202
1212
  jwks: {
1213
+ rotationInterval: number;
1214
+ gracePeriod: number;
1203
1215
  keyPairConfigs: {
1204
1216
  readonly alg: "ES256";
1205
1217
  }[];
@@ -1462,6 +1474,21 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1462
1474
  };
1463
1475
  };
1464
1476
  };
1477
+ } | {
1478
+ id: "protobase-signing-keys";
1479
+ endpoints: {
1480
+ ensureSigningKey: import("better-call").StrictEndpoint<string, {
1481
+ method: "POST";
1482
+ }, {
1483
+ ok: boolean;
1484
+ }>;
1485
+ };
1486
+ hooks: {
1487
+ before: {
1488
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
1489
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
1490
+ }[];
1491
+ };
1465
1492
  } | {
1466
1493
  hooks: {
1467
1494
  after: {
@@ -8787,6 +8814,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8787
8814
  };
8788
8815
  basePath: string;
8789
8816
  secret: string;
8817
+ secrets: VersionedSecret[];
8790
8818
  trustedOrigins: string[] | ((request?: Request) => string[]);
8791
8819
  emailAndPassword: {
8792
8820
  onPasswordReset?: ((data: {
@@ -30,6 +30,8 @@ import { coreNames, pluginNames } from "./snake-case-names.js";
30
30
  import { passkeyPlugin } from "./passkey-plugin.js";
31
31
  import { passwordResetOptions } from "./password-reset.js";
32
32
  import { rolesPlugin } from "./roles/roles-plugin.js";
33
+ import { versionedSecrets } from "./secret-versions.js";
34
+ import { signingKeyRotation, signingKeys } from "./signing-keys.js";
33
35
  import { platformSignInFor, platformSignInProvider, readPlatformSignIn, resolvePlatformSignIn } from "./platform-sign-in.js";
34
36
  import { appSignInPolicy } from "./app-sign-in-policy.js";
35
37
  import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
@@ -110,7 +112,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
110
112
  };
111
113
  const jwtOptions = {
112
114
  schema: pluginNames.jwt,
113
- jwks: { keyPairConfigs: [goTrueKeyPair] },
115
+ jwks: {
116
+ keyPairConfigs: [goTrueKeyPair],
117
+ ...signingKeyRotation(options)
118
+ },
114
119
  jwt: {
115
120
  expirationTime: tokenLifetime,
116
121
  issuer: options.baseURL,
@@ -130,6 +135,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
130
135
  },
131
136
  disableSettingJwtHeader: true
132
137
  };
138
+ const keys = signingKeys(jwtOptions);
139
+ const { secrets, legacySecret } = versionedSecrets(options.secret, options.previousSecrets);
133
140
  const trusted = [
134
141
  options.baseURL,
135
142
  ...origins,
@@ -146,7 +153,8 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
146
153
  logger: authLogger,
147
154
  baseURL: multiHostBaseURL(options.baseURL, origins),
148
155
  basePath: authBasePath,
149
- secret: options.secret,
156
+ secret: legacySecret,
157
+ secrets,
150
158
  trustedOrigins: mobile ? (request) => [...trusted, ...mobile.trusted(request)] : trusted,
151
159
  emailAndPassword: {
152
160
  enabled: true,
@@ -175,6 +183,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
175
183
  roles: Object.fromEntries(roles.map((role) => [role, role === "admin" ? adminAc : userAc]))
176
184
  }),
177
185
  jwt(jwtOptions),
186
+ keys.plugin,
178
187
  twoFactorAfterPasswordOrCode({
179
188
  schema: pluginNames.twoFactor,
180
189
  allowPasswordless: true,
@@ -201,6 +210,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
201
210
  goTrueSessionsPlugin({
202
211
  issuer: goTrueIssuer(options.baseURL),
203
212
  jwt: jwtOptions,
213
+ signingKey: keys.ensure,
204
214
  policy
205
215
  }),
206
216
  staffSignInPlugin({
@@ -0,0 +1,7 @@
1
+ //#region src/better-auth/secret-versions.d.ts
2
+ /** Better Auth's versioned secrets: the current one first, each with the version its ciphertexts name. */
3
+ export type VersionedSecret = {
4
+ version: number;
5
+ value: string;
6
+ };
7
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+ //#region src/better-auth/secret-versions.ts
3
+ const versionOf = (secret) => Number.parseInt(createHash("sha256").update(`protobase-secret-version:${secret}`).digest("hex").slice(0, 7), 16);
4
+ /**
5
+ * Better Auth's options for the app's `secret` and its `previousSecrets`: `secrets`, which encrypt with the current
6
+ * one and decrypt with any of them, and `secret`, which Better Auth keeps for what was encrypted before secrets had
7
+ * versions. That is the oldest secret listed: the one in use when the app last ran without versions, unless it was
8
+ * changed since without being listed.
9
+ */
10
+ const versionedSecrets = (secret, previousSecrets = []) => {
11
+ const all = [.../* @__PURE__ */ new Set([secret, ...previousSecrets])];
12
+ return {
13
+ secrets: all.map((value) => ({
14
+ version: versionOf(value),
15
+ value
16
+ })),
17
+ legacySecret: all.at(-1)
18
+ };
19
+ };
20
+ //#endregion
21
+ export { versionedSecrets };
@@ -0,0 +1,86 @@
1
+ import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
2
+ import { createJwk } from "better-auth/plugins";
3
+ const daySeconds = 86400;
4
+ /** The JWT plugin's rotation for `options`: `rotationInterval` and `gracePeriod`, in seconds. */
5
+ const signingKeyRotation = ({ signingKeyDays = 30, signingKeyGraceDays = 7 } = {}) => {
6
+ if (!(signingKeyDays > 0)) throw new Error("signingKeyDays must be more than 0");
7
+ if (!(signingKeyGraceDays >= 1)) throw new Error("signingKeyGraceDays must be at least 1: tokens of `protobase token` last up to a day");
8
+ return {
9
+ rotationInterval: Math.round(signingKeyDays * daySeconds),
10
+ gracePeriod: Math.round(signingKeyGraceDays * daySeconds)
11
+ };
12
+ };
13
+ const isPrimary = (key) => key.alg === null || key.alg === void 0 || key.alg === "EdDSA";
14
+ const marginMs = 6e4;
15
+ const signingPaths = /* @__PURE__ */ new Set(["/token", "/oauth2/token"]);
16
+ /**
17
+ * Keeps a live EdDSA key for the app's tokens. The JWT plugin signs with the newest live key of the primary algorithm,
18
+ * else with the newest live key of any: once the EdDSA key expired while the ES256 one of the GoTrue-compatible
19
+ * tokens still lived, it would sign the app's tokens ES256 until that one expired too. So before every signing, a new
20
+ * EdDSA key is made when none lives; a key made before rotation was on (without an expiry) gets the one it would have
21
+ * had, its creation plus the rotation interval, so it rotates too. A key past its grace period, which no longer
22
+ * verifies, is deleted, so the store keeps a few keys (Better Auth reads at most 100). The key set is read once per
23
+ * key's lifetime: until the newest EdDSA key expires, signing reads nothing more.
24
+ */
25
+ const signingKeys = (jwt) => {
26
+ const intervalMs = jwt.jwks.rotationInterval * 1e3;
27
+ const graceMs = jwt.jwks.gracePeriod * 1e3;
28
+ let liveUntil = 0;
29
+ let pending;
30
+ const check = async (ctx) => {
31
+ const now = Date.now();
32
+ const { adapter } = ctx.context;
33
+ const dated = (await adapter.findMany({ model: "jwks" })).map((key) => ({
34
+ ...key,
35
+ undated: !key.expiresAt,
36
+ expiresAt: key.expiresAt ?? new Date(key.createdAt.getTime() + intervalMs)
37
+ }));
38
+ const retired = dated.filter((key) => key.expiresAt.getTime() + graceMs <= now);
39
+ if (retired.length > 0) await adapter.deleteMany({
40
+ model: "jwks",
41
+ where: [{
42
+ field: "id",
43
+ operator: "in",
44
+ value: retired.map((key) => key.id)
45
+ }]
46
+ });
47
+ const keys = dated.filter((key) => !retired.includes(key));
48
+ await Promise.all(keys.filter((key) => key.undated).map((key) => adapter.update({
49
+ model: "jwks",
50
+ where: [{
51
+ field: "id",
52
+ value: key.id
53
+ }],
54
+ update: { expiresAt: key.expiresAt }
55
+ })));
56
+ liveUntil = (keys.filter((key) => isPrimary(key) && key.expiresAt.getTime() - marginMs > now).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? await createJwk(ctx, jwt)).expiresAt.getTime() - marginMs;
57
+ };
58
+ /** Makes sure a live EdDSA key signs the next token; concurrent callers share one check. */
59
+ const ensure = async (ctx) => {
60
+ if (Date.now() < liveUntil) return;
61
+ pending ??= check(ctx).finally(() => {
62
+ pending = void 0;
63
+ });
64
+ await pending;
65
+ };
66
+ return {
67
+ plugin: {
68
+ id: "protobase-signing-keys",
69
+ endpoints: {
70
+ /** Makes sure a live EdDSA key signs the next token, for signing outside the plugin's paths (`issueToken`). */
71
+ ensureSigningKey: createAuthEndpoint.serverOnly({ method: "POST" }, async (ctx) => {
72
+ await ensure(ctx);
73
+ return ctx.json({ ok: true });
74
+ }) },
75
+ hooks: { before: [{
76
+ matcher: (ctx) => signingPaths.has(ctx.path ?? ""),
77
+ handler: createAuthMiddleware(async (ctx) => {
78
+ await ensure(ctx);
79
+ })
80
+ }] }
81
+ },
82
+ ensure
83
+ };
84
+ };
85
+ //#endregion
86
+ export { signingKeyRotation, signingKeys };
@@ -24,6 +24,7 @@ const issueToken = async (auth, options) => {
24
24
  if (user.banned) throw new Error(`${options.email} is banned`);
25
25
  const issuedAt = Math.floor(Date.now() / 1e3);
26
26
  const { role } = narrowClaims({ role: parseRoles(user.role).join(",") }, [user.lastRole], Boolean(auth.organizations));
27
+ await auth.api.ensureSigningKey();
27
28
  const { token } = await auth.api.signJWT({ body: { payload: {
28
29
  iat: issuedAt,
29
30
  exp: issuedAt + ttl,
@@ -4,7 +4,7 @@ import { goTrueUser } from "./user-json.js";
4
4
  import { accessClaims, goTrueSession } from "./session-json.js";
5
5
  import * as z$1 from "zod";
6
6
  import { APIError, createAuthEndpoint } from "better-auth/api";
7
- import { createJwk, signJWT } from "better-auth/plugins";
7
+ import { signJWT } from "better-auth/plugins";
8
8
  import { generateRandomString } from "better-auth/crypto";
9
9
  //#region src/gotrue/sessions-plugin.ts
10
10
  const refreshTokenNotFound = () => new APIError("BAD_REQUEST", {
@@ -16,13 +16,7 @@ const refreshTokenNotFound = () => new APIError("BAD_REQUEST", {
16
16
  * so signing out anywhere, a ban or a revoked session ends it, and refreshing gives the session a new token, so each
17
17
  * refresh token works once. Server-side endpoints only, for `goTrueRoutes`.
18
18
  */
19
- const goTrueSessionsPlugin = ({ issuer, jwt, policy }) => {
20
- let primaryKeyMade = false;
21
- const makePrimaryKey = async (ctx) => {
22
- if (primaryKeyMade) return;
23
- if (!(await ctx.context.adapter.findMany({ model: "jwks" })).some((key) => key.alg === null || key.alg === void 0 || key.alg === "EdDSA")) await createJwk(ctx, jwt);
24
- primaryKeyMade = true;
25
- };
19
+ const goTrueSessionsPlugin = ({ issuer, jwt, signingKey, policy }) => {
26
20
  const sessionOf = async (ctx, sessionToken) => {
27
21
  const found = await ctx.context.internalAdapter.findSession(sessionToken);
28
22
  if (!found) throw refreshTokenNotFound();
@@ -31,7 +25,7 @@ const goTrueSessionsPlugin = ({ issuer, jwt, policy }) => {
31
25
  if (refusal) throw new APIError("FORBIDDEN", refusal);
32
26
  const accounts = await ctx.context.internalAdapter.findAccounts(user.id);
33
27
  const now = Math.floor(Date.now() / 1e3);
34
- await makePrimaryKey(ctx);
28
+ await signingKey(ctx);
35
29
  const accessToken = await signJWT(ctx, {
36
30
  options: jwt,
37
31
  payload: accessClaims({
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.49.0";
2
+ var version = "0.49.1";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.49.0",
3
+ "version": "0.49.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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.49.0",
44
- "@protobase/query": "0.49.0",
45
- "@protobase/schema": "0.49.0",
43
+ "@protobase/layout": "0.49.1",
44
+ "@protobase/query": "0.49.1",
45
+ "@protobase/schema": "0.49.1",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",