@protobase/server 0.46.7 → 0.46.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.
@@ -111,6 +111,12 @@ export type CreateAuthOptions = {
111
111
  * Supabase Auth's. The first sign-in with one stores the password again as Better Auth hashes it. Default none.
112
112
  */
113
113
  passwordHashes?: PasswordHash[];
114
+ /**
115
+ * Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
116
+ * an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
117
+ * the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
118
+ */
119
+ leakedPasswords?: boolean;
114
120
  /**
115
121
  * The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
116
122
  * to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
@@ -4511,6 +4517,17 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
4511
4517
  status: boolean;
4512
4518
  }>;
4513
4519
  };
4520
+ } | {
4521
+ id: "protobase-leaked-passwords";
4522
+ hooks: {
4523
+ before: {
4524
+ matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
4525
+ handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
4526
+ }[];
4527
+ };
4528
+ $ERROR_CODES: {
4529
+ PASSWORD_COMPROMISED: import("better-auth").RawError<"PASSWORD_COMPROMISED">;
4530
+ };
4514
4531
  } | {
4515
4532
  id: "protobase-gotrue-sessions";
4516
4533
  endpoints: {
@@ -8790,6 +8807,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
8790
8807
  /** The policy until an admin saves one. */
8791
8808
  signInPolicy: SignInPolicy;
8792
8809
  passwordReset: boolean;
8810
+ /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
8811
+ leakedPasswords: boolean;
8793
8812
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
8794
8813
  signUp: boolean;
8795
8814
  /** The role of accounts made by open sign-up, while it is offered. */
@@ -13,6 +13,9 @@ import { platformMailer } from "../mail/platform-mailer.js";
13
13
  import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
14
14
  import { defaultInvitationUrl } from "./invitations/invitation-link-hook.js";
15
15
  import { lastAdminGuardPlugin } from "./last-admin-guard.js";
16
+ import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
17
+ import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
18
+ import { leakedPasswordsPlugin } from "./leaked-passwords.js";
16
19
  import { userDeletion } from "./user-deletion.js";
17
20
  import { revokeMcpGrants } from "./mcp/revoke-grants.js";
18
21
  import { coreNames, pluginNames } from "./snake-case-names.js";
@@ -25,7 +28,6 @@ import { resolveOrganizations } from "./organizations/options.js";
25
28
  import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey-domain.js";
26
29
  import { passkeyPlugin } from "./passkey-plugin.js";
27
30
  import { passwordResetOptions } from "./password-reset.js";
28
- import { setPasswordPath, setPasswordPlugin } from "./set-password.js";
29
31
  import { rolesPlugin } from "./roles/roles-plugin.js";
30
32
  import { platformSignInFor, platformSignInProvider, readPlatformSignIn, resolvePlatformSignIn } from "./platform-sign-in.js";
31
33
  import { appSignInPolicy } from "./app-sign-in-policy.js";
@@ -33,7 +35,6 @@ import { assertNoEndpointKeyClashes } from "./endpoint-key-clashes.js";
33
35
  import { emailInDomains, resolveSamlProviders, samlDisabledPaths, samlGuardPlugin, samlPlugin } from "./saml-sign-in.js";
34
36
  import { noAccountMailPlugin } from "./no-account-mail.js";
35
37
  import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
36
- import { signUpPaths, signUpPlugin } from "./sign-up/sign-up-plugin.js";
37
38
  import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./email-confirmed.js";
38
39
  import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
39
40
  import { staffSignInPlugin } from "./staff-plugin.js";
@@ -182,6 +183,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
182
183
  ...saml.length > 0 ? [samlPlugin(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
183
184
  signInPolicyPlugin(policy),
184
185
  setPasswordPlugin(),
186
+ ...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
185
187
  goTrueSessionsPlugin({
186
188
  issuer: goTrueIssuer(options.baseURL),
187
189
  jwt: jwtOptions,
@@ -384,6 +386,8 @@ const createAuth = (options) => {
384
386
  /** The policy until an admin saves one. */
385
387
  signInPolicy,
386
388
  passwordReset: Boolean(mailer),
389
+ /** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
390
+ leakedPasswords: Boolean(options.leakedPasswords),
387
391
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
388
392
  signUp: Boolean(signUpRole && mailer),
389
393
  /** The role of accounts made by open sign-up, while it is offered. */
@@ -0,0 +1,47 @@
1
+ import { passwordLength } from "./new-account-password.js";
2
+ import { setPasswordPath } from "./set-password.js";
3
+ import { signUpPaths } from "./sign-up/sign-up-plugin.js";
4
+ import { APIError, createAuthMiddleware } from "better-auth/api";
5
+ import { haveIBeenPwned, isPasswordCompromised } from "better-auth/plugins/haveibeenpwned";
6
+ //#region src/better-auth/leaked-passwords.ts
7
+ const { PASSWORD_COMPROMISED } = haveIBeenPwned().$ERROR_CODES;
8
+ const leakedPasswordMessage = "This password appears in a known data breach. Choose a different one.";
9
+ const passwordFields = {
10
+ "/sign-up/email": "password",
11
+ "/change-password": "newPassword",
12
+ "/reset-password": "newPassword",
13
+ "/admin/create-user": "password",
14
+ "/admin/set-user-password": "newPassword",
15
+ [setPasswordPath]: "newPassword",
16
+ [signUpPaths.create]: "password",
17
+ "/invitation/sign-up": "password"
18
+ };
19
+ /** Refuses a password from the Have I Been Pwned corpus with `400 PASSWORD_COMPROMISED`, as Better Auth's plugin does. */
20
+ const rejectLeakedPassword = async (password) => {
21
+ if (!await isPasswordCompromised(password)) return;
22
+ throw APIError.from("BAD_REQUEST", {
23
+ code: PASSWORD_COMPROMISED.code,
24
+ message: leakedPasswordMessage
25
+ });
26
+ };
27
+ /**
28
+ * Better Auth's haveIBeenPwned check, on every endpoint of `passwordFields`. It runs before the endpoint, where the
29
+ * plugin itself checks only once the password is hashed: admin account creation, invitation sign-up and sign-up have
30
+ * made the account or used the ticket by then. Only the first five characters of the password's SHA-1 hash leave the
31
+ * server; a password outside the allowed lengths is left to the endpoint, which refuses it without a lookup. When the
32
+ * list cannot be reached, the request fails with a 500.
33
+ */
34
+ const leakedPasswordsPlugin = () => ({
35
+ id: "protobase-leaked-passwords",
36
+ hooks: { before: [{
37
+ matcher: (ctx) => ctx.path !== void 0 && Object.hasOwn(passwordFields, ctx.path),
38
+ handler: createAuthMiddleware(async (ctx) => {
39
+ const password = ctx.body?.[passwordFields[ctx.path]];
40
+ if (typeof password !== "string" || password.length < passwordLength.min || password.length > passwordLength.max) return;
41
+ await rejectLeakedPassword(password);
42
+ })
43
+ }] },
44
+ $ERROR_CODES: { PASSWORD_COMPROMISED }
45
+ });
46
+ //#endregion
47
+ export { leakedPasswordsPlugin, rejectLeakedPassword };
@@ -2,9 +2,9 @@ import { pluginNames } from "./snake-case-names.js";
2
2
  import * as z$1 from "zod";
3
3
  import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
4
4
  import { generateRandomString } from "better-auth/crypto";
5
+ import { setSessionCookie } from "better-auth/cookies";
5
6
  import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
6
7
  import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
7
- import { setSessionCookie } from "better-auth/cookies";
8
8
  //#region src/better-auth/passkey-plugin.ts
9
9
  const challengeCookie = "better-auth-passkey";
10
10
  const challengeSeconds = 300;
@@ -3,6 +3,7 @@ import { readSignInPolicy } from "../better-auth/policy-store.js";
3
3
  import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
4
4
  import { createSignedUpAccount } from "../better-auth/sign-up/sign-up-account.js";
5
5
  import { consumeSignUpTicket } from "../better-auth/sign-up/sign-up-ticket.js";
6
+ import { rejectLeakedPassword } from "../better-auth/leaked-passwords.js";
6
7
  import { GoTrueError } from "./errors.js";
7
8
  import { callBetterAuth, signedInToken } from "./forward.js";
8
9
  import { hasPendingSignUp, savePendingSignUp, takePendingSignUp } from "./pending-sign-up.js";
@@ -79,6 +80,7 @@ const emailCodeRoutes = ({ auth, context, sessionOf }) => {
79
80
  if (!auth.signUp) throw signUpDisabled();
80
81
  checkMetadata(data);
81
82
  checkNewAccountPassword(await signUpPolicy(), password);
83
+ if (auth.leakedPasswords) await rejectLeakedPassword(password);
82
84
  await sendSignUpCode(c.req.raw, email);
83
85
  await savePendingSignUp(await context(), email, {
84
86
  passwordHash: await (await context()).password.hash(password),
@@ -35,7 +35,8 @@ const known = {
35
35
  BANNED_USER: [403, "user_banned"],
36
36
  NO_ADMIN_YET: [422, "signup_disabled"],
37
37
  PASSWORD_LENGTH: [422, "weak_password"],
38
- PASSWORD_REQUIRED: [422, "weak_password"]
38
+ PASSWORD_REQUIRED: [422, "weak_password"],
39
+ PASSWORD_COMPROMISED: [422, "weak_password"]
39
40
  };
40
41
  /** A refusal of Better Auth, by its status and body, as GoTrue's: a known code becomes GoTrue's, any other its own in lower case. */
41
42
  const fromBetterAuth = (status, body) => {
@@ -2,6 +2,7 @@ import { effectiveSignInPolicy } from "../better-auth/sign-in-policy.js";
2
2
  import { readSignInPolicy } from "../better-auth/policy-store.js";
3
3
  import { goTrueUser, userMetadata } from "./user-json.js";
4
4
  import { checkNewAccountPassword } from "../better-auth/new-account-password.js";
5
+ import { rejectLeakedPassword } from "../better-auth/leaked-passwords.js";
5
6
  import { revokeMcpGrants } from "../better-auth/mcp/revoke-grants.js";
6
7
  import { goTrueTokens } from "./access-tokens.js";
7
8
  import { GoTrueError } from "./errors.js";
@@ -75,6 +76,7 @@ const userRoutes = ({ auth, context }) => {
75
76
  if (data !== void 0 && !auth.userMetadata) throw new GoTrueError(400, "validation_failed", "This app keeps no user metadata: leave data out");
76
77
  if (password !== void 0) {
77
78
  checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.adapter, auth.signInPolicy)).policy, { mail: auth.mail }), password);
79
+ if (auth.leakedPasswords) await rejectLeakedPassword(password);
78
80
  const hash = await ctx.password.hash(password);
79
81
  if (await ctx.internalAdapter.findCredentialAccount(user.id)) await ctx.internalAdapter.updatePassword(user.id, hash);
80
82
  else await ctx.internalAdapter.linkAccount({
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.46.7";
2
+ var version = "0.46.8";
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.46.7",
3
+ "version": "0.46.8",
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": {
@@ -36,9 +36,9 @@
36
36
  "@better-auth/passkey": "^1.7.12",
37
37
  "@better-auth/sso": "^1.7.12",
38
38
  "@modelcontextprotocol/server": "^2.3.1",
39
- "@protobase/layout": "0.46.7",
40
- "@protobase/query": "0.46.7",
41
- "@protobase/schema": "0.46.7",
39
+ "@protobase/layout": "0.46.8",
40
+ "@protobase/query": "0.46.8",
41
+ "@protobase/schema": "0.46.8",
42
42
  "@react-email/render": "^2.1.0",
43
43
  "@scalar/hono-api-reference": "^0.12.10",
44
44
  "@sentry/core": "^11.7.0",