@protobase/server 0.66.6 → 0.66.8
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/assistant/delete-record-tool.d.ts +1 -1
- package/dist/assistant/delete-record-tool.js +11 -11
- package/dist/better-auth/auth-mail/auth-emails.d.ts +2 -0
- package/dist/better-auth/auth-mail/auth-emails.js +3 -1
- package/dist/better-auth/auth-mail/confirmation-code-email.d.ts +1 -0
- package/dist/better-auth/auth-mail/confirmation-code-email.js +32 -0
- package/dist/better-auth/auth-mail/password-changed-email.d.ts +1 -0
- package/dist/better-auth/auth-mail/password-changed-email.js +32 -0
- package/dist/better-auth/auth-settings.d.ts +108 -2
- package/dist/better-auth/confirmation-code-plugin.d.ts +9 -0
- package/dist/better-auth/confirmation-code-plugin.js +95 -0
- package/dist/better-auth/confirmation-codes.js +54 -0
- package/dist/better-auth/create-auth.d.ts +119 -3
- package/dist/better-auth/create-auth.js +34 -7
- package/dist/better-auth/mcp/revoke-grants.js +7 -4
- package/dist/better-auth/mobile-requests.js +23 -3
- package/dist/better-auth/password-change.js +68 -0
- package/dist/better-auth/password-checks.js +69 -0
- package/dist/better-auth/password-reset.js +2 -1
- package/dist/better-auth/recent-confirmation.js +58 -0
- package/dist/better-auth/sign-in-mail.js +8 -5
- package/dist/better-auth/sign-up/sign-up-code.js +4 -3
- package/dist/better-auth/sign-up/sign-up-plugin.js +3 -2
- package/dist/better-auth/staff-checks.js +2 -0
- package/dist/better-auth/strong-sign-ins.js +2 -2
- package/dist/built-in-resources.d.ts +89 -0
- package/dist/built-in-resources.js +31 -0
- package/dist/changes/capture-schema.js +3 -1
- package/dist/comments/comments-sql.d.ts +4 -1
- package/dist/comments/comments-sql.js +16 -7
- package/dist/comments/record-comments.js +1 -1
- package/dist/config-exports.d.ts +3 -1
- package/dist/config-exports.js +3 -1
- package/dist/create-admin.js +9 -16
- package/dist/gotrue/user-routes.js +6 -4
- package/dist/has-indexes.js +9 -0
- package/dist/history/history-sql.js +13 -4
- package/dist/internal.js +1 -1
- package/dist/mcp/mcp-input-schemas.js +24 -0
- package/dist/mcp/mcp-server.js +3 -9
- package/dist/package.js +1 -1
- package/dist/reveal-service.js +1 -1
- package/dist/webhooks/table/schema-sql.js +30 -6
- package/dist/webhooks/table/schema.js +10 -4
- package/package.json +4 -4
|
@@ -13,13 +13,14 @@ import { endReplacedSession } from "./end-replaced-session.js";
|
|
|
13
13
|
import { authBasePath } from "./auth-base-path.js";
|
|
14
14
|
import { authLogger } from "./auth-logger.js";
|
|
15
15
|
import { cookiePrefixOf } from "./cookie-prefix.js";
|
|
16
|
-
import { mobileRequests } from "./mobile-requests.js";
|
|
16
|
+
import { jsonOnlyOnMobile, mobileRequests } from "./mobile-requests.js";
|
|
17
17
|
import { passwordLength } from "./new-account-password.js";
|
|
18
18
|
import { platformMailer } from "../mail/platform-mailer.js";
|
|
19
19
|
import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
|
|
20
20
|
import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
|
|
21
21
|
import { lastAdminGuardPlugin } from "./last-admin-guard.js";
|
|
22
22
|
import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
|
|
23
|
+
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
23
24
|
import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
|
|
24
25
|
import { leakedPasswordsPlugin } from "./leaked-passwords.js";
|
|
25
26
|
import { lockedAccountsPlugin, readLockedAccounts } from "./locked-accounts.js";
|
|
@@ -35,6 +36,8 @@ import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey
|
|
|
35
36
|
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
36
37
|
import { passkeyPlugin } from "./passkey-plugin.js";
|
|
37
38
|
import { passwordResetOptions } from "./password-reset.js";
|
|
39
|
+
import { mailPasswordChanged, passwordChangePlugin } from "./password-change.js";
|
|
40
|
+
import { passwordChecksPlugin } from "./password-checks.js";
|
|
38
41
|
import { rolesPlugin } from "./roles/roles-plugin.js";
|
|
39
42
|
import { versionedSecrets } from "./secret-versions.js";
|
|
40
43
|
import { signingKeyRotation, signingKeys } from "./signing-keys.js";
|
|
@@ -43,11 +46,12 @@ import { appSignInPolicy } from "./app-sign-in-policy.js";
|
|
|
43
46
|
import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
|
|
44
47
|
import { emailInDomains, resolveSamlProviders, samlDisabledPaths, samlGuardPlugin } from "./saml-sign-in.js";
|
|
45
48
|
import { noAccountMailPlugin } from "./no-account-mail.js";
|
|
46
|
-
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
47
49
|
import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
|
|
48
50
|
import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
|
|
49
51
|
import { staffSignInPlugin } from "./staff-plugin.js";
|
|
50
52
|
import { strongSignInsPlugin } from "./strong-sign-ins.js";
|
|
53
|
+
import { recentConfirmationPlugin } from "./recent-confirmation.js";
|
|
54
|
+
import { confirmationCodePaths, confirmationCodePlugin } from "./confirmation-code-plugin.js";
|
|
51
55
|
import { tokenClaims } from "./token-claims.js";
|
|
52
56
|
import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
|
|
53
57
|
import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
|
|
@@ -177,7 +181,11 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
177
181
|
minPasswordLength: passwordLength.min,
|
|
178
182
|
maxPasswordLength: passwordLength.max,
|
|
179
183
|
...passwordHashes.length > 0 && { password: { verify: verifyAcceptedHash(passwordHashes) } },
|
|
180
|
-
...mail && passwordResetOptions(mail,
|
|
184
|
+
...mail && passwordResetOptions(mail, { onPasswordReset: async ({ user }) => {
|
|
185
|
+
const auth = await context();
|
|
186
|
+
if (mcp) await revokeMcpGrants(auth.adapter, user.id);
|
|
187
|
+
await mailPasswordChanged({ context: auth }, mail, user.email);
|
|
188
|
+
} })
|
|
181
189
|
},
|
|
182
190
|
...options.socialProviders && { socialProviders: options.socialProviders },
|
|
183
191
|
user: {
|
|
@@ -185,7 +193,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
185
193
|
...Object.keys(userFields).length > 0 && { additionalFields: userFields },
|
|
186
194
|
...deleteUser
|
|
187
195
|
},
|
|
188
|
-
session:
|
|
196
|
+
session: {
|
|
197
|
+
...coreNames.session,
|
|
198
|
+
freshAge: 0
|
|
199
|
+
},
|
|
189
200
|
verification: coreNames.verification,
|
|
190
201
|
account: {
|
|
191
202
|
...coreNames.account,
|
|
@@ -213,7 +224,12 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
213
224
|
origins: appOrigins(options.baseURL, origins)
|
|
214
225
|
}),
|
|
215
226
|
strongSignInsPlugin(),
|
|
216
|
-
|
|
227
|
+
recentConfirmationPlugin(),
|
|
228
|
+
...mail ? [
|
|
229
|
+
emailOTP(emailCodeOptions(mail)),
|
|
230
|
+
noAccountMailPlugin(mail),
|
|
231
|
+
confirmationCodePlugin(mail)
|
|
232
|
+
] : [],
|
|
217
233
|
...mail && signUpRole ? [signUpPlugin({
|
|
218
234
|
role: signUpRole,
|
|
219
235
|
mail,
|
|
@@ -222,7 +238,9 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
222
238
|
...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
|
|
223
239
|
...saml.length > 0 ? [loaded(plugins.samlPlugin, "sso.saml")(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
|
|
224
240
|
signInPolicyPlugin(policy),
|
|
241
|
+
passwordChecksPlugin(),
|
|
225
242
|
setPasswordPlugin(),
|
|
243
|
+
passwordChangePlugin(mail),
|
|
226
244
|
...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
|
|
227
245
|
goTrueSessionsPlugin({
|
|
228
246
|
issuer: goTrueIssuer(options.baseURL),
|
|
@@ -335,6 +353,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
335
353
|
[signUpPaths.create]: {
|
|
336
354
|
window: 60,
|
|
337
355
|
max: options.signInPerMinute ?? 5
|
|
356
|
+
},
|
|
357
|
+
[confirmationCodePaths.send]: {
|
|
358
|
+
window: 60,
|
|
359
|
+
max: 5
|
|
360
|
+
},
|
|
361
|
+
[confirmationCodePaths.verify]: {
|
|
362
|
+
window: 60,
|
|
363
|
+
max: 5
|
|
338
364
|
}
|
|
339
365
|
}
|
|
340
366
|
},
|
|
@@ -412,9 +438,10 @@ const createAuth = (options, plugins = {}) => {
|
|
|
412
438
|
return Object.assign(auth, {
|
|
413
439
|
/**
|
|
414
440
|
* Answers a request from the app's iOS or Android build: Better Auth also trusts the Capacitor origin the request
|
|
415
|
-
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin.
|
|
441
|
+
* names. Only `createAdmin` with a `mobile` section uses it; `handler` and `api` never trust a Capacitor origin. A
|
|
442
|
+
* Capacitor origin's POST without a JSON body is refused before it is granted, as only a page sends one.
|
|
416
443
|
*/
|
|
417
|
-
handleMobile: (request) => auth.handler(mobile.grant(request)),
|
|
444
|
+
handleMobile: async (request) => jsonOnlyOnMobile(request) ?? auth.handler(mobile.grant(request)),
|
|
418
445
|
/**
|
|
419
446
|
* What runs when a user is deleted: `set` a named listener that removes what the app keeps of the user elsewhere,
|
|
420
447
|
* such as their devices; `run` is for a deletion outside Better Auth's endpoints (`deleteUser`).
|
|
@@ -28,7 +28,11 @@ const revokeMcpGrants = async (adapter, userId, clientId) => {
|
|
|
28
28
|
where
|
|
29
29
|
});
|
|
30
30
|
};
|
|
31
|
-
const ownPaths = [
|
|
31
|
+
const ownPaths = [
|
|
32
|
+
"/revoke-sessions",
|
|
33
|
+
"/revoke-other-sessions",
|
|
34
|
+
"/change-password"
|
|
35
|
+
];
|
|
32
36
|
const adminPaths = [
|
|
33
37
|
"/admin/revoke-user-sessions",
|
|
34
38
|
"/admin/ban-user",
|
|
@@ -42,14 +46,14 @@ const securesAccount = (data) => {
|
|
|
42
46
|
};
|
|
43
47
|
/**
|
|
44
48
|
* The user whose MCP grants a finished request ends, or `undefined`: the person who signed out everywhere, signed out
|
|
45
|
-
* their other devices, or changed their password
|
|
49
|
+
* their other devices, or changed their password; the user an admin ended the sessions
|
|
46
50
|
* of, banned, set the password of, or marked unverified. Nobody when the request failed.
|
|
47
51
|
*/
|
|
48
52
|
const grantOwnerToRevoke = (request) => {
|
|
49
53
|
const { path, context } = request;
|
|
50
54
|
if (context.returned instanceof Error || path === void 0) return void 0;
|
|
51
55
|
const body = bodyOf(request);
|
|
52
|
-
if (ownPaths.includes(path)
|
|
56
|
+
if (ownPaths.includes(path)) return context.session?.user.id;
|
|
53
57
|
if (adminPaths.includes(path) || path === "/admin/update-user" && securesAccount(body.data)) return body.userId === void 0 ? void 0 : String(body.userId);
|
|
54
58
|
};
|
|
55
59
|
/**
|
|
@@ -62,7 +66,6 @@ const mcpGrantRevocation = () => ({
|
|
|
62
66
|
matcher: (context) => context.path !== void 0 && [
|
|
63
67
|
...ownPaths,
|
|
64
68
|
...adminPaths,
|
|
65
|
-
"/change-password",
|
|
66
69
|
"/admin/update-user"
|
|
67
70
|
].includes(context.path),
|
|
68
71
|
handler: createAuthMiddleware(async (ctx) => {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { capacitorOrigins } from "../mobile-config.js";
|
|
2
2
|
//#region src/better-auth/mobile-requests.ts
|
|
3
|
+
const capacitorOriginOf = (request) => {
|
|
4
|
+
const origin = request.headers.get("origin");
|
|
5
|
+
return origin !== null && capacitorOrigins.includes(origin);
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* Which requests come from the app's iOS or Android build: `grant` marks a request, and `trusted` gives the Capacitor
|
|
5
9
|
* origin it names as an origin Better Auth trusts, for that very request only. Any other request, or an Origin that is
|
|
@@ -14,10 +18,26 @@ const mobileRequests = () => {
|
|
|
14
18
|
},
|
|
15
19
|
trusted: (request) => {
|
|
16
20
|
if (!request || !granted.has(request)) return [];
|
|
17
|
-
|
|
18
|
-
return origin !== null && capacitorOrigins.includes(origin) ? [origin] : [];
|
|
21
|
+
return capacitorOriginOf(request) ? [request.headers.get("origin")] : [];
|
|
19
22
|
}
|
|
20
23
|
};
|
|
21
24
|
};
|
|
25
|
+
const mediaType = (request) => request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
|
|
26
|
+
/**
|
|
27
|
+
* Refuses a POST from a Capacitor origin whose body is not JSON. A page can send a form, `text/plain` or multipart POST
|
|
28
|
+
* without a preflight, but not a JSON one, and `/api/auth` answers no preflight: so the grant only ever sees requests
|
|
29
|
+
* the app's native HTTP sends. A request from any other origin is left alone, such as an identity provider's form POST.
|
|
30
|
+
*/
|
|
31
|
+
const jsonOnlyOnMobile = (request) => {
|
|
32
|
+
if (request.method !== "POST" || !capacitorOriginOf(request)) return;
|
|
33
|
+
if (mediaType(request) === "application/json") return;
|
|
34
|
+
return new Response(JSON.stringify({
|
|
35
|
+
message: "A request from the app takes a JSON body only",
|
|
36
|
+
code: "UNSUPPORTED_MEDIA_TYPE"
|
|
37
|
+
}), {
|
|
38
|
+
status: 415,
|
|
39
|
+
headers: { "content-type": "application/json" }
|
|
40
|
+
});
|
|
41
|
+
};
|
|
22
42
|
//#endregion
|
|
23
|
-
export { mobileRequests };
|
|
43
|
+
export { jsonOnlyOnMobile, mobileRequests };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { setPasswordPath } from "./set-password.js";
|
|
2
|
+
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
3
|
+
import * as z$1 from "zod";
|
|
4
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
|
5
|
+
//#region src/better-auth/password-change.ts
|
|
6
|
+
/**
|
|
7
|
+
* Mails `email` that its account's password changed, when the app has mail: in the background, as Better Auth sends
|
|
8
|
+
* its own mail, so the answer does not wait on the mail server.
|
|
9
|
+
*/
|
|
10
|
+
const mailPasswordChanged = async (ctx, mail, email) => {
|
|
11
|
+
if (!mail) return;
|
|
12
|
+
await ctx.context.runInBackgroundOrAwait(sendAuthEmail(mail, "passwordChangedEmail", email, {}, ctx.context.baseURL));
|
|
13
|
+
};
|
|
14
|
+
const ownChanges = ["/change-password", setPasswordPath];
|
|
15
|
+
const adminChange = "/admin/set-user-password";
|
|
16
|
+
/** Whose account a finished request gave a new password: the address of the person, or the id of the user an admin set it for. */
|
|
17
|
+
const changedAccount = ({ path, body, context }) => {
|
|
18
|
+
if (context.returned instanceof Error || path === void 0) return;
|
|
19
|
+
if (ownChanges.includes(path)) return context.session ? { email: context.session.user.email } : void 0;
|
|
20
|
+
const userId = typeof body === "object" && body !== null ? body.userId : void 0;
|
|
21
|
+
if (path === adminChange && userId !== void 0) return { userId: String(userId) };
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A password change ends the account's other sessions and tells its owner:
|
|
25
|
+
* - `/change-password` always signs out the other devices, whatever `revokeOtherSessions` says: this device gets a
|
|
26
|
+
* new session, and the MCP grant revocation disconnects the account's MCP clients.
|
|
27
|
+
* - When the app has mail, the owner is emailed after `/change-password`, a first password (`/account/set-password`)
|
|
28
|
+
* and an admin's `/admin/set-user-password`; `passwordChanged` mails them for a change outside these endpoints,
|
|
29
|
+
* such as the GoTrue-compatible `PUT /user`. A password reset mails them from `onPasswordReset`.
|
|
30
|
+
*/
|
|
31
|
+
const passwordChangePlugin = (mail) => ({
|
|
32
|
+
id: "protobase-password-change",
|
|
33
|
+
endpoints: { passwordChanged: createAuthEndpoint.serverOnly({
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: z$1.object({ email: z$1.string() })
|
|
36
|
+
}, async (ctx) => {
|
|
37
|
+
await mailPasswordChanged(ctx, mail, ctx.body.email);
|
|
38
|
+
return ctx.json({ status: true });
|
|
39
|
+
}) },
|
|
40
|
+
hooks: {
|
|
41
|
+
before: [{
|
|
42
|
+
matcher: (ctx) => ctx.path === "/change-password",
|
|
43
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
44
|
+
if (typeof ctx.body !== "object" || ctx.body === null) return;
|
|
45
|
+
return { context: { body: {
|
|
46
|
+
...ctx.body,
|
|
47
|
+
revokeOtherSessions: true
|
|
48
|
+
} } };
|
|
49
|
+
})
|
|
50
|
+
}],
|
|
51
|
+
after: [{
|
|
52
|
+
matcher: (ctx) => ctx.path !== void 0 && [...ownChanges, adminChange].includes(ctx.path),
|
|
53
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
54
|
+
const changed = changedAccount(ctx);
|
|
55
|
+
if (!changed || !mail) return;
|
|
56
|
+
if ("email" in changed) {
|
|
57
|
+
await mailPasswordChanged(ctx, mail, changed.email);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const user = await ctx.context.internalAdapter.findUserById(changed.userId);
|
|
61
|
+
if (!user) throw new APIError("NOT_FOUND", { message: "The user whose password was set is gone" });
|
|
62
|
+
await mailPasswordChanged(ctx, mail, user.email);
|
|
63
|
+
})
|
|
64
|
+
}]
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
//#endregion
|
|
68
|
+
export { changedAccount, mailPasswordChanged, passwordChangePlugin };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
|
|
2
|
+
const maxTrackedAccounts = 1e4;
|
|
3
|
+
const checkedFields = {
|
|
4
|
+
"/change-password": "currentPassword",
|
|
5
|
+
"/verify-password": "password",
|
|
6
|
+
"/delete-user": "password",
|
|
7
|
+
"/two-factor/enable": "password",
|
|
8
|
+
"/two-factor/disable": "password",
|
|
9
|
+
"/two-factor/get-totp-uri": "password",
|
|
10
|
+
"/two-factor/generate-backup-codes": "password"
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Counts the checks of each account in a fixed window that opens at its first check: `take` answers how many seconds
|
|
14
|
+
* the account waits before its next check, or `undefined` when this one may go ahead and is counted. Bounded: a window
|
|
15
|
+
* that ended is dropped, and at `maxTracked` accounts the oldest window is dropped.
|
|
16
|
+
*/
|
|
17
|
+
const checkCounter = ({ max, windowSeconds, maxTracked = maxTrackedAccounts, now = Date.now }) => {
|
|
18
|
+
const windows = /* @__PURE__ */ new Map();
|
|
19
|
+
const windowMs = windowSeconds * 1e3;
|
|
20
|
+
const prune = (at) => {
|
|
21
|
+
for (const [key, window] of windows) if (at - window.opened >= windowMs) windows.delete(key);
|
|
22
|
+
while (windows.size >= maxTracked) windows.delete(windows.keys().next().value);
|
|
23
|
+
};
|
|
24
|
+
return { take: (key) => {
|
|
25
|
+
const at = now();
|
|
26
|
+
const window = windows.get(key);
|
|
27
|
+
if (window && at - window.opened < windowMs) {
|
|
28
|
+
if (window.count >= max) return Math.ceil((window.opened + windowMs - at) / 1e3);
|
|
29
|
+
window.count += 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
windows.delete(key);
|
|
33
|
+
prune(at);
|
|
34
|
+
windows.set(key, {
|
|
35
|
+
opened: at,
|
|
36
|
+
count: 1
|
|
37
|
+
});
|
|
38
|
+
} };
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Limits the current-password checks behind a session to `passwordChecksPerWindow` an account a minute, on top of
|
|
42
|
+
* Better Auth's limit per client address, so a stolen session cannot guess the password from many addresses. Every
|
|
43
|
+
* check counts, a right one too, and is counted before the password is compared, so requests sent at once cannot pass
|
|
44
|
+
* the limit together. A refused check answers `429 TOO_MANY_PASSWORD_CHECKS`, with `X-Retry-After` in seconds. A
|
|
45
|
+
* request without a session is left to the endpoint, which refuses it. The count lives in this process's memory.
|
|
46
|
+
*/
|
|
47
|
+
const passwordChecksPlugin = (counter = checkCounter({
|
|
48
|
+
max: 5,
|
|
49
|
+
windowSeconds: 60
|
|
50
|
+
})) => ({
|
|
51
|
+
id: "protobase-password-checks",
|
|
52
|
+
hooks: { before: [{
|
|
53
|
+
matcher: (ctx) => ctx.path !== void 0 && Object.hasOwn(checkedFields, ctx.path),
|
|
54
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
55
|
+
const password = ctx.body?.[checkedFields[ctx.path]];
|
|
56
|
+
if (typeof password !== "string" || password === "") return;
|
|
57
|
+
const session = await getSessionFromCtx(ctx);
|
|
58
|
+
if (!session) return;
|
|
59
|
+
const wait = counter.take(session.user.id);
|
|
60
|
+
if (wait === void 0) return;
|
|
61
|
+
throw new APIError("TOO_MANY_REQUESTS", {
|
|
62
|
+
code: "TOO_MANY_PASSWORD_CHECKS",
|
|
63
|
+
message: "Too many password attempts. Try again in a minute."
|
|
64
|
+
}, { "X-Retry-After": String(wait) });
|
|
65
|
+
})
|
|
66
|
+
}] }
|
|
67
|
+
});
|
|
68
|
+
//#endregion
|
|
69
|
+
export { checkCounter, passwordChecksPlugin };
|
|
@@ -5,7 +5,8 @@ const resetLinkLifetimeSeconds = 3600;
|
|
|
5
5
|
const resetLinkLifetime = "1 hour";
|
|
6
6
|
/**
|
|
7
7
|
* Better Auth's own password reset, sending the link with `mail`. Resetting ends the user's sessions, so a reset
|
|
8
|
-
* after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients
|
|
8
|
+
* after a leaked password also signs out whoever used it, and `onPasswordReset` disconnects the user's MCP clients
|
|
9
|
+
* and tells them their password changed.
|
|
9
10
|
*/
|
|
10
11
|
const passwordResetOptions = (mail, { onPasswordReset } = {}) => ({
|
|
11
12
|
resetPasswordTokenExpiresIn: resetLinkLifetimeSeconds,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isStaffSession } from "./staff-checks.js";
|
|
2
|
+
import { strongSignInAt } from "./strong-sign-ins.js";
|
|
3
|
+
import { confirmationMinutes } from "@protobase/schema/internal";
|
|
4
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
|
|
5
|
+
//#region src/better-auth/recent-confirmation.ts
|
|
6
|
+
/** How long after signing in a session counts as recent without confirming: Better Auth's own default fresh age, a day. */
|
|
7
|
+
const recentSessionSeconds = 86400;
|
|
8
|
+
/** The refusal of an account change the session has to confirm first, which the account page answers by asking in place. */
|
|
9
|
+
const confirmationRequired = {
|
|
10
|
+
code: "STRONG_SIGN_IN_REQUIRED",
|
|
11
|
+
message: `Confirm it's you first: this change needs a passkey, a second step or an emailed code in the last ${confirmationMinutes} minutes.`
|
|
12
|
+
};
|
|
13
|
+
const passkeyPaths = ["/passkey/generate-register-options", "/passkey/verify-registration"];
|
|
14
|
+
const guardedPaths = [
|
|
15
|
+
...passkeyPaths,
|
|
16
|
+
"/unlink-account",
|
|
17
|
+
"/delete-user"
|
|
18
|
+
];
|
|
19
|
+
const asksToConfirm = async (context, path, body, { user }) => {
|
|
20
|
+
if (path === "/delete-user") return !body?.password;
|
|
21
|
+
if (!passkeyPaths.includes(path)) return true;
|
|
22
|
+
if (user.twoFactorEnabled) return true;
|
|
23
|
+
return await context.adapter.count({
|
|
24
|
+
model: "passkey",
|
|
25
|
+
where: [{
|
|
26
|
+
field: "userId",
|
|
27
|
+
value: user.id
|
|
28
|
+
}]
|
|
29
|
+
}) > 0;
|
|
30
|
+
};
|
|
31
|
+
/** Whether the session signed in within a day, or confirmed it's them within `confirmationMinutes`. */
|
|
32
|
+
const isRecentlyConfirmed = async (context, session, now = /* @__PURE__ */ new Date()) => {
|
|
33
|
+
if (now.getTime() - new Date(session.createdAt).getTime() < 864e5) return true;
|
|
34
|
+
const at = await strongSignInAt(context.internalAdapter, session.id, now);
|
|
35
|
+
return at !== void 0 && now.getTime() - at.getTime() <= confirmationMinutes * 6e4;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Replaces Better Auth's fresh-session check, which `createAuth` turns off (`freshAge: 0`), and which could only be met
|
|
39
|
+
* by signing out and in again. A session of any age adds a first passkey, and lists its devices. Adding another passkey
|
|
40
|
+
* to an account with a passkey or a second step, unlinking a sign-in provider and deleting the account without a
|
|
41
|
+
* password ask a session older than a day to confirm it's them in place first (`strongSignInsPlugin` notes it), else
|
|
42
|
+
* `403 STRONG_SIGN_IN_REQUIRED`. Staff sessions are left to the staff plugin, which refuses these changes.
|
|
43
|
+
*/
|
|
44
|
+
const recentConfirmationPlugin = () => ({
|
|
45
|
+
id: "protobase-recent-confirmation",
|
|
46
|
+
hooks: { before: [{
|
|
47
|
+
matcher: (context) => context.path !== void 0 && guardedPaths.includes(context.path),
|
|
48
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
49
|
+
const signedIn = await getSessionFromCtx(ctx);
|
|
50
|
+
if (!signedIn || isStaffSession(signedIn.session)) return;
|
|
51
|
+
if (!await asksToConfirm(ctx.context, ctx.path, ctx.body, signedIn)) return;
|
|
52
|
+
if (await isRecentlyConfirmed(ctx.context, signedIn.session)) return;
|
|
53
|
+
throw new APIError("FORBIDDEN", confirmationRequired);
|
|
54
|
+
})
|
|
55
|
+
}] }
|
|
56
|
+
});
|
|
57
|
+
//#endregion
|
|
58
|
+
export { confirmationRequired, isRecentlyConfirmed, recentConfirmationPlugin, recentSessionSeconds };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { emailCodeMinutes } from "@protobase/schema/internal";
|
|
3
|
+
//#region src/better-auth/sign-in-mail.ts
|
|
4
|
+
/** How long an emailed code works: in seconds for the sign-in code, and in the email's words. */
|
|
5
|
+
const codeLifetimeSeconds = emailCodeMinutes * 60;
|
|
6
|
+
const codeLifetime = `${emailCodeMinutes} minutes`;
|
|
4
7
|
/**
|
|
5
8
|
* Better Auth's email OTP plugin for signing in and changing the address: never a sign-up, codes stored hashed, sent
|
|
6
9
|
* with `mail`. A new address gets its own email, with the code that moves the account to it. The plugin's other uses
|
|
@@ -8,7 +11,7 @@ const codeLifetime = "10 minutes";
|
|
|
8
11
|
*/
|
|
9
12
|
const emailCodeOptions = (mail) => ({
|
|
10
13
|
disableSignUp: true,
|
|
11
|
-
expiresIn:
|
|
14
|
+
expiresIn: codeLifetimeSeconds,
|
|
12
15
|
storeOTP: "hashed",
|
|
13
16
|
changeEmail: { enabled: true },
|
|
14
17
|
sendVerificationOTP: ({ email, otp, type }, ctx) => sendAuthEmail(mail, type === "change-email" ? "changeEmailCodeEmail" : "signInCodeEmail", email, {
|
|
@@ -26,7 +29,7 @@ const unusedEmailCodePaths = [
|
|
|
26
29
|
];
|
|
27
30
|
/** The two-factor plugin's emailed code, sent with `mail`; stored hashed. */
|
|
28
31
|
const twoFactorCodeOptions = (mail) => ({
|
|
29
|
-
period:
|
|
32
|
+
period: emailCodeMinutes,
|
|
30
33
|
storeOTP: "hashed",
|
|
31
34
|
sendOTP: ({ user, otp }, ctx) => sendAuthEmail(mail, "twoFactorCodeEmail", user.email, {
|
|
32
35
|
code: otp,
|
|
@@ -34,4 +37,4 @@ const twoFactorCodeOptions = (mail) => ({
|
|
|
34
37
|
}, ctx?.context.baseURL)
|
|
35
38
|
});
|
|
36
39
|
//#endregion
|
|
37
|
-
export { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
|
|
40
|
+
export { codeLifetime, codeLifetimeSeconds, emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { codeLifetimeSeconds } from "../sign-in-mail.js";
|
|
1
2
|
import { APIError } from "better-auth/api";
|
|
2
|
-
|
|
3
|
+
//#region src/better-auth/sign-up/sign-up-code.ts
|
|
3
4
|
const allowedAttempts = 3;
|
|
4
5
|
const identifierOf = (email) => `sign-up-otp-${email}`;
|
|
5
6
|
/** The SHA-256 of `secret`, base64: what the store keeps of a code or a ticket. */
|
|
@@ -26,7 +27,7 @@ const issueSignUpCode = async (context, email) => {
|
|
|
26
27
|
await context.internalAdapter.createVerificationValue({
|
|
27
28
|
identifier,
|
|
28
29
|
value: `${await hashOf(code)}:0`,
|
|
29
|
-
expiresAt: new Date(Date.now() +
|
|
30
|
+
expiresAt: new Date(Date.now() + codeLifetimeSeconds * 1e3)
|
|
30
31
|
});
|
|
31
32
|
return code;
|
|
32
33
|
};
|
|
@@ -50,4 +51,4 @@ const consumeSignUpCode = async (context, email, code) => {
|
|
|
50
51
|
throw invalidCode();
|
|
51
52
|
};
|
|
52
53
|
//#endregion
|
|
53
|
-
export { consumeSignUpCode, hashOf, issueSignUpCode
|
|
54
|
+
export { consumeSignUpCode, hashOf, issueSignUpCode };
|
|
@@ -2,8 +2,9 @@ import { effectiveSignInPolicy } from "../sign-in-policy.js";
|
|
|
2
2
|
import { readSignInPolicy } from "../policy-store.js";
|
|
3
3
|
import { checkNewAccountPassword } from "../new-account-password.js";
|
|
4
4
|
import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
|
|
5
|
+
import { codeLifetime } from "../sign-in-mail.js";
|
|
5
6
|
import { createSignedUpAccount } from "./sign-up-account.js";
|
|
6
|
-
import { consumeSignUpCode, issueSignUpCode
|
|
7
|
+
import { consumeSignUpCode, issueSignUpCode } from "./sign-up-code.js";
|
|
7
8
|
import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
|
|
8
9
|
import * as z$1 from "zod";
|
|
9
10
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
@@ -40,7 +41,7 @@ const signUpPlugin = ({ role, mail, defaultPolicy }) => ({
|
|
|
40
41
|
if (await adapter.count({ model: "user" }) === 0) throw noAdminYet();
|
|
41
42
|
const sending = await internalAdapter.findUserByEmail(email) ? sendAuthEmail(mail, "accountExistsEmail", email, {}, ctx.context.baseURL) : sendAuthEmail(mail, "signUpCodeEmail", email, {
|
|
42
43
|
code: await issueSignUpCode(ctx.context, email),
|
|
43
|
-
lifetime:
|
|
44
|
+
lifetime: codeLifetime
|
|
44
45
|
}, ctx.context.baseURL);
|
|
45
46
|
await ctx.context.runInBackgroundOrAwait(sending);
|
|
46
47
|
return ctx.json({ sent: true });
|
|
@@ -55,6 +55,8 @@ const accountChanges = [
|
|
|
55
55
|
"/passkey/verify-registration",
|
|
56
56
|
"/passkey/update-passkey",
|
|
57
57
|
"/passkey/delete-passkey",
|
|
58
|
+
"/account/confirmation-code/send",
|
|
59
|
+
"/account/confirmation-code/verify",
|
|
58
60
|
"/invitation/accept",
|
|
59
61
|
"/organization/leave",
|
|
60
62
|
"/organization/transfer-ownership",
|
|
@@ -12,7 +12,7 @@ const twoFactorPaths = [
|
|
|
12
12
|
*/
|
|
13
13
|
const strongSignInPaths = ["/passkey/verify-authentication", ...twoFactorPaths];
|
|
14
14
|
const identifierOf = (sessionId) => `protobase-strong-sign-in-${sessionId}`;
|
|
15
|
-
/** Notes that the session proved a passkey
|
|
15
|
+
/** Notes that the session proved a passkey, a second step or an emailed confirmation code now; the note ends with the session. */
|
|
16
16
|
const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Date()) => {
|
|
17
17
|
const identifier = identifierOf(session.id);
|
|
18
18
|
await internal.deleteVerificationByIdentifier(identifier);
|
|
@@ -22,7 +22,7 @@ const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Dat
|
|
|
22
22
|
expiresAt: session.expiresAt
|
|
23
23
|
});
|
|
24
24
|
};
|
|
25
|
-
/** When the session last proved a passkey
|
|
25
|
+
/** When the session last proved a passkey, a second step or an emailed confirmation code, if it did and its note has not expired. */
|
|
26
26
|
const strongSignInAt = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
|
|
27
27
|
const found = await internal.findVerificationValue(identifierOf(sessionId));
|
|
28
28
|
if (!found || new Date(found.expiresAt) <= now) return void 0;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EventsOptions } from "./changes/events-options.js";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
//#region src/built-in-resources.d.ts
|
|
4
|
+
export type BuiltInInput = {
|
|
5
|
+
models: ResourceModel[];
|
|
6
|
+
/** Whether the app declares jobs of its own. */
|
|
7
|
+
jobs: boolean;
|
|
8
|
+
/** Whether the app sends notifications. */
|
|
9
|
+
notifications: boolean;
|
|
10
|
+
events?: EventsOptions;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
|
|
14
|
+
* jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
|
|
15
|
+
* with comments.
|
|
16
|
+
*/
|
|
17
|
+
export declare const builtInResources: ({ models, jobs, notifications, events }: BuiltInInput) => {
|
|
18
|
+
resources: (import("@protobase/schema").ResourceBuilder<{
|
|
19
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
20
|
+
readonly name: import("@protobase/schema").Field<string>;
|
|
21
|
+
readonly error: import("@protobase/schema").Field<string | null>;
|
|
22
|
+
readonly attempts: import("@protobase/schema").Field<number>;
|
|
23
|
+
readonly failed_at: import("@protobase/schema").Field<string>;
|
|
24
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
25
|
+
readonly key: import("@protobase/schema").Field<string | null>;
|
|
26
|
+
readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
27
|
+
readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
28
|
+
readonly retry: import("@protobase/schema").Field<boolean>;
|
|
29
|
+
}, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
|
|
30
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
31
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
32
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
33
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
34
|
+
readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
|
|
35
|
+
readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
36
|
+
readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
37
|
+
readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
38
|
+
readonly actor_id: import("@protobase/schema").Field<string | null>;
|
|
39
|
+
readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
40
|
+
readonly database_role: import("@protobase/schema").Field<string | null>;
|
|
41
|
+
readonly transaction: import("@protobase/schema").Field<string>;
|
|
42
|
+
readonly at: import("@protobase/schema").Field<string>;
|
|
43
|
+
}, import("@protobase/schema").Ref<"id", string>> | import("@protobase/schema").ResourceBuilder<{
|
|
44
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
45
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
46
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
47
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
48
|
+
readonly author_id: import("@protobase/schema").Field<string>;
|
|
49
|
+
readonly body: import("@protobase/schema").Field<string>;
|
|
50
|
+
readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
51
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
52
|
+
}, import("@protobase/schema").Ref<"id", string>>)[];
|
|
53
|
+
views: (import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
54
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
55
|
+
readonly name: import("@protobase/schema").Field<string>;
|
|
56
|
+
readonly error: import("@protobase/schema").Field<string | null>;
|
|
57
|
+
readonly attempts: import("@protobase/schema").Field<number>;
|
|
58
|
+
readonly failed_at: import("@protobase/schema").Field<string>;
|
|
59
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
60
|
+
readonly key: import("@protobase/schema").Field<string | null>;
|
|
61
|
+
readonly payload: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
62
|
+
readonly output: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
63
|
+
readonly retry: import("@protobase/schema").Field<boolean>;
|
|
64
|
+
}, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
65
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
66
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
67
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
68
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
69
|
+
readonly operation: import("@protobase/schema").Field<"create" | "update" | "delete" | "undelete">;
|
|
70
|
+
readonly before: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
71
|
+
readonly after: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
72
|
+
readonly changed: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
73
|
+
readonly actor_id: import("@protobase/schema").Field<string | null>;
|
|
74
|
+
readonly actor_roles: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
75
|
+
readonly database_role: import("@protobase/schema").Field<string | null>;
|
|
76
|
+
readonly transaction: import("@protobase/schema").Field<string>;
|
|
77
|
+
readonly at: import("@protobase/schema").Field<string>;
|
|
78
|
+
}, import("@protobase/schema").Ref<"id", string>>> | import("@protobase/schema").ViewBuilder<import("@protobase/schema").ResourceBuilder<{
|
|
79
|
+
readonly id: import("@protobase/schema").Field<string>;
|
|
80
|
+
readonly resource: import("@protobase/schema").Field<string>;
|
|
81
|
+
readonly record_key: import("@protobase/schema").Field<string>;
|
|
82
|
+
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
83
|
+
readonly author_id: import("@protobase/schema").Field<string>;
|
|
84
|
+
readonly body: import("@protobase/schema").Field<string>;
|
|
85
|
+
readonly mentions: import("@protobase/schema").Field<import("zod").JSONType>;
|
|
86
|
+
readonly created_at: import("@protobase/schema").Field<string>;
|
|
87
|
+
}, import("@protobase/schema").Ref<"id", string>>>)[];
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { hasAiFields } from "./ai/ai-fields.js";
|
|
2
|
+
import { isCaptured } from "./changes/capture-sql.js";
|
|
3
|
+
import { changesSchema } from "./changes/events-options.js";
|
|
4
|
+
import { commentsResource, commentsView } from "./comments/record-comments.js";
|
|
5
|
+
import { activityResource, activityView } from "./history/activity.js";
|
|
6
|
+
import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
|
|
7
|
+
//#region src/built-in-resources.ts
|
|
8
|
+
/**
|
|
9
|
+
* The resources and views the API adds beside the app's own, by what the app uses: Failed jobs once anything runs
|
|
10
|
+
* jobs (events, history, comments, AI fields, notifications or the app's jobs), Activity with history and Comments
|
|
11
|
+
* with comments.
|
|
12
|
+
*/
|
|
13
|
+
const builtInResources = ({ models, jobs, notifications, events }) => {
|
|
14
|
+
const withComments = models.some((model) => model.comments);
|
|
15
|
+
const withJobs = jobs || models.some(isCaptured) || withComments || hasAiFields(models) || notifications;
|
|
16
|
+
const withHistory = models.some((model) => model.history);
|
|
17
|
+
return {
|
|
18
|
+
resources: [
|
|
19
|
+
...withJobs ? [failedJobs] : [],
|
|
20
|
+
...withHistory ? [activityResource(changesSchema(events), { tenant: models.some((model) => model.history && model.tenant) })] : [],
|
|
21
|
+
...withComments ? [commentsResource({ tenant: models.some((model) => model.comments && model.tenant) })] : []
|
|
22
|
+
],
|
|
23
|
+
views: [
|
|
24
|
+
...withJobs ? [failedJobsView] : [],
|
|
25
|
+
...withHistory ? [activityView] : [],
|
|
26
|
+
...withComments ? [commentsView] : []
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { builtInResources };
|