@protobase/server 0.46.7 → 0.46.9
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/better-auth/create-auth.d.ts +31 -0
- package/dist/better-auth/create-auth.js +8 -2
- package/dist/better-auth/leaked-passwords.js +47 -0
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/strong-sign-ins.js +64 -0
- package/dist/better-auth/token-standing.js +9 -2
- package/dist/gotrue/email-code-routes.js +2 -0
- package/dist/gotrue/errors.js +2 -1
- package/dist/gotrue/user-routes.js +2 -0
- package/dist/openapi/resource-paths.js +4 -3
- package/dist/package.js +1 -1
- package/dist/resource-source.d.ts +3 -1
- package/dist/reveal-service.js +68 -19
- package/dist/types.d.ts +3 -1
- package/package.json +4 -4
|
@@ -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
|
|
@@ -2587,6 +2593,18 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
2587
2593
|
RESOLVED_USER_INVALID: import("better-auth").RawError<"RESOLVED_USER_INVALID">;
|
|
2588
2594
|
};
|
|
2589
2595
|
options: import("@better-auth/passkey").PasskeyOptions | undefined;
|
|
2596
|
+
} | {
|
|
2597
|
+
id: "protobase-strong-sign-ins";
|
|
2598
|
+
hooks: {
|
|
2599
|
+
before: {
|
|
2600
|
+
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
2601
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2602
|
+
}[];
|
|
2603
|
+
after: {
|
|
2604
|
+
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
2605
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2606
|
+
}[];
|
|
2607
|
+
};
|
|
2590
2608
|
} | {
|
|
2591
2609
|
id: "email-otp";
|
|
2592
2610
|
version: string;
|
|
@@ -4511,6 +4529,17 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
4511
4529
|
status: boolean;
|
|
4512
4530
|
}>;
|
|
4513
4531
|
};
|
|
4532
|
+
} | {
|
|
4533
|
+
id: "protobase-leaked-passwords";
|
|
4534
|
+
hooks: {
|
|
4535
|
+
before: {
|
|
4536
|
+
matcher: (ctx: import("better-auth").HookEndpointContext) => boolean;
|
|
4537
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
4538
|
+
}[];
|
|
4539
|
+
};
|
|
4540
|
+
$ERROR_CODES: {
|
|
4541
|
+
PASSWORD_COMPROMISED: import("better-auth").RawError<"PASSWORD_COMPROMISED">;
|
|
4542
|
+
};
|
|
4514
4543
|
} | {
|
|
4515
4544
|
id: "protobase-gotrue-sessions";
|
|
4516
4545
|
endpoints: {
|
|
@@ -8790,6 +8819,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
8790
8819
|
/** The policy until an admin saves one. */
|
|
8791
8820
|
signInPolicy: SignInPolicy;
|
|
8792
8821
|
passwordReset: boolean;
|
|
8822
|
+
/** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
|
|
8823
|
+
leakedPasswords: boolean;
|
|
8793
8824
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
8794
8825
|
signUp: boolean;
|
|
8795
8826
|
/** 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,10 +35,10 @@ 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";
|
|
41
|
+
import { strongSignInsPlugin } from "./strong-sign-ins.js";
|
|
40
42
|
import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
|
|
41
43
|
import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
|
|
42
44
|
import { usersPlugin } from "./users/users-plugin.js";
|
|
@@ -172,6 +174,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
172
174
|
formerDomain: hosts[0],
|
|
173
175
|
origins: appOrigins(options.baseURL, origins)
|
|
174
176
|
}),
|
|
177
|
+
strongSignInsPlugin(),
|
|
175
178
|
...mail ? [emailOTP(emailCodeOptions(mail)), noAccountMailPlugin(mail)] : [],
|
|
176
179
|
...mail && signUpRole ? [signUpPlugin({
|
|
177
180
|
role: signUpRole,
|
|
@@ -182,6 +185,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
182
185
|
...saml.length > 0 ? [samlPlugin(saml, options.baseURL), samlGuardPlugin(samlIds)] : [],
|
|
183
186
|
signInPolicyPlugin(policy),
|
|
184
187
|
setPasswordPlugin(),
|
|
188
|
+
...options.leakedPasswords ? [leakedPasswordsPlugin()] : [],
|
|
185
189
|
goTrueSessionsPlugin({
|
|
186
190
|
issuer: goTrueIssuer(options.baseURL),
|
|
187
191
|
jwt: jwtOptions,
|
|
@@ -384,6 +388,8 @@ const createAuth = (options) => {
|
|
|
384
388
|
/** The policy until an admin saves one. */
|
|
385
389
|
signInPolicy,
|
|
386
390
|
passwordReset: Boolean(mailer),
|
|
391
|
+
/** New passwords are checked against Have I Been Pwned's list of leaked ones (`leakedPasswords`). */
|
|
392
|
+
leakedPasswords: Boolean(options.leakedPasswords),
|
|
387
393
|
/** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
|
|
388
394
|
signUp: Boolean(signUpRole && mailer),
|
|
389
395
|
/** 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;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { isStaffSession } from "./staff-checks.js";
|
|
2
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
|
|
3
|
+
//#region src/better-auth/strong-sign-ins.ts
|
|
4
|
+
const twoFactorPaths = [
|
|
5
|
+
"/two-factor/verify-totp",
|
|
6
|
+
"/two-factor/verify-otp",
|
|
7
|
+
"/two-factor/verify-backup-code"
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* The Better Auth endpoints that prove a passkey or a second step: each signs in a new session, or confirms the one the
|
|
11
|
+
* request's cookie names, as the second step does for someone signed in already.
|
|
12
|
+
*/
|
|
13
|
+
const strongSignInPaths = ["/passkey/verify-authentication", ...twoFactorPaths];
|
|
14
|
+
const identifierOf = (sessionId) => `protobase-strong-sign-in-${sessionId}`;
|
|
15
|
+
/** Notes that the session proved a passkey or a second step now; the note ends with the session. */
|
|
16
|
+
const saveStrongSignIn = async (internal, session, now = /* @__PURE__ */ new Date()) => {
|
|
17
|
+
const identifier = identifierOf(session.id);
|
|
18
|
+
await internal.deleteVerificationByIdentifier(identifier);
|
|
19
|
+
await internal.createVerificationValue({
|
|
20
|
+
identifier,
|
|
21
|
+
value: now.toISOString(),
|
|
22
|
+
expiresAt: session.expiresAt
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
/** When the session last proved a passkey or a second step, if it did and its note has not expired. */
|
|
26
|
+
const strongSignInAt = async (internal, sessionId, now = /* @__PURE__ */ new Date()) => {
|
|
27
|
+
const found = await internal.findVerificationValue(identifierOf(sessionId));
|
|
28
|
+
if (!found || new Date(found.expiresAt) <= now) return void 0;
|
|
29
|
+
return new Date(found.value);
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Records the sessions that sign in, or confirm, with a passkey or a second step, since Better Auth's sessions do not
|
|
33
|
+
* say how they began: a reveal that asks for a recent strong sign-in reads it (`strongSignInAt`). The session is the
|
|
34
|
+
* one the answer signed in, or else the one the request's cookie names. A staff session is never recorded: staff
|
|
35
|
+
* proved nothing as the person. Nor is the code that turns two-factor authentication on, which a session alone may
|
|
36
|
+
* ask for on an account without a password: it proves the authenticator app just added, not the person.
|
|
37
|
+
*/
|
|
38
|
+
const strongSignInsPlugin = () => {
|
|
39
|
+
const turningOn = /* @__PURE__ */ new WeakSet();
|
|
40
|
+
return {
|
|
41
|
+
id: "protobase-strong-sign-ins",
|
|
42
|
+
hooks: {
|
|
43
|
+
before: [{
|
|
44
|
+
matcher: (context) => context.path !== void 0 && twoFactorPaths.includes(context.path),
|
|
45
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
46
|
+
const signedIn = await getSessionFromCtx(ctx);
|
|
47
|
+
if (signedIn && !signedIn.user.twoFactorEnabled && ctx.headers) turningOn.add(ctx.headers);
|
|
48
|
+
})
|
|
49
|
+
}],
|
|
50
|
+
after: [{
|
|
51
|
+
matcher: (context) => context.path !== void 0 && strongSignInPaths.includes(context.path),
|
|
52
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
53
|
+
if (ctx.context.returned instanceof APIError) return;
|
|
54
|
+
if (ctx.headers && turningOn.has(ctx.headers)) return;
|
|
55
|
+
const session = ctx.context.newSession?.session ?? (await getSessionFromCtx(ctx))?.session;
|
|
56
|
+
if (!session || isStaffSession(session)) return;
|
|
57
|
+
await saveStrongSignIn(ctx.context.internalAdapter, session);
|
|
58
|
+
})
|
|
59
|
+
}]
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
64
|
+
export { saveStrongSignIn, strongSignInAt, strongSignInPaths, strongSignInsPlugin };
|
|
@@ -4,6 +4,7 @@ import { parseRoles } from "./parse-roles.js";
|
|
|
4
4
|
import { effectiveRoles, globalRoles } from "./organizations/app-roles.js";
|
|
5
5
|
import { findMember, findOrganization } from "./organizations/store.js";
|
|
6
6
|
import { isStaffSession } from "./staff-checks.js";
|
|
7
|
+
import { strongSignInAt } from "./strong-sign-ins.js";
|
|
7
8
|
//#region src/better-auth/token-standing.ts
|
|
8
9
|
/**
|
|
9
10
|
* The sign-in a session row stands for: staff started it when the admin plugin's `impersonatedBy` names someone. A row
|
|
@@ -53,7 +54,8 @@ const checkUserStanding = async (context, claims, organizations, now = /* @__PUR
|
|
|
53
54
|
* Whether a token still stands, read from the store on every request: its session is not signed out or expired, and
|
|
54
55
|
* its user still stands (`checkUserStanding`). A token that fails is a 401, so the client asks its session for a new
|
|
55
56
|
* one, which carries the roles as they are now or is refused. Answers the sign-in the token's session stands for
|
|
56
|
-
* (`signInOf`), `undefined` for a token without one
|
|
57
|
+
* (`signInOf`), `undefined` for a token without one; the user's own one also reads, when asked, when it last proved a
|
|
58
|
+
* passkey or a second step (`strongSignInAt`).
|
|
57
59
|
*/
|
|
58
60
|
const checkTokenStanding = async (context, claims, organizations, now = /* @__PURE__ */ new Date()) => {
|
|
59
61
|
const session = claims.sessionId === void 0 ? void 0 : await context.adapter.findOne({
|
|
@@ -65,7 +67,12 @@ const checkTokenStanding = async (context, claims, organizations, now = /* @__PU
|
|
|
65
67
|
});
|
|
66
68
|
if (claims.sessionId !== void 0 && (!session || session.userId !== claims.userId || session.expiresAt <= now)) throw revoked();
|
|
67
69
|
await checkUserStanding(context, claims, organizations, now);
|
|
68
|
-
|
|
70
|
+
const signIn = session ? signInOf(session, claims.sessionId) : void 0;
|
|
71
|
+
if (!signIn || signIn.staff) return signIn;
|
|
72
|
+
return {
|
|
73
|
+
...signIn,
|
|
74
|
+
strongAt: () => strongSignInAt(context.internalAdapter, claims.sessionId)
|
|
75
|
+
};
|
|
69
76
|
};
|
|
70
77
|
//#endregion
|
|
71
78
|
export { checkTokenStanding, checkUserStanding, signInOf, tokenClaims };
|
|
@@ -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),
|
package/dist/gotrue/errors.js
CHANGED
|
@@ -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({
|
|
@@ -192,7 +192,7 @@ const resourcePaths = (access) => {
|
|
|
192
192
|
...auth,
|
|
193
193
|
404: error("NotFound")
|
|
194
194
|
};
|
|
195
|
-
const sensitive = Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name);
|
|
195
|
+
const sensitive = [...Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name), ...Object.keys(entry.source.computedReveals ?? {})];
|
|
196
196
|
const aiActions = (pickView(access.deps.views, access.session.user.roles, entry.name)?.actions ?? []).filter((action) => action.run?.kind === "ai").map((action) => action.name);
|
|
197
197
|
const paths = {
|
|
198
198
|
[`/${entry.name}`]: {
|
|
@@ -330,7 +330,7 @@ const resourcePaths = (access) => {
|
|
|
330
330
|
operationId: `reveal_${entry.name}`,
|
|
331
331
|
tags: tag,
|
|
332
332
|
summary: `Reveal a sensitive field of a ${entry.name} record`,
|
|
333
|
-
description: "A sensitive field never comes with the record. This returns its value for one record, with the same access as a get, and publishes an audit event first.",
|
|
333
|
+
description: "A sensitive field never comes with the record. This returns its value for one record, or makes the value of a computed reveal, with the same access as a get, and publishes an audit event first. A field that needs a recent strong sign-in is a 403 `strong-sign-in-required` until the caller confirms it's them with a passkey or a second step.",
|
|
334
334
|
parameters: [param("key")],
|
|
335
335
|
requestBody: {
|
|
336
336
|
required: true,
|
|
@@ -356,7 +356,8 @@ const resourcePaths = (access) => {
|
|
|
356
356
|
}
|
|
357
357
|
})
|
|
358
358
|
},
|
|
359
|
-
...keyErrors
|
|
359
|
+
...keyErrors,
|
|
360
|
+
403: error("Forbidden")
|
|
360
361
|
}
|
|
361
362
|
} } },
|
|
362
363
|
...aiActions.length > 0 && { [`/${entry.name}/{key}:generate`]: { post: {
|
package/dist/package.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SubscriptionDefinition } from "./webhooks/subscription.js";
|
|
2
2
|
import "./index.js";
|
|
3
3
|
import { PageDefinition } from "@protobase/layout";
|
|
4
|
-
import { AccessSource, BottomBarSource, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
4
|
+
import { AccessSource, BottomBarSource, ComputedReveal, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
//#region src/resource-source.d.ts
|
|
7
7
|
type FieldSource = {
|
|
@@ -24,6 +24,8 @@ export type ResourceSource = AccessSource & {
|
|
|
24
24
|
message: string;
|
|
25
25
|
}> | undefined)[];
|
|
26
26
|
recordSchema(): z.ZodType;
|
|
27
|
+
/** Its values revealed like sensitive fields but computed when asked (`.reveals()`), by name. */
|
|
28
|
+
readonly computedReveals?: Readonly<Record<string, ComputedReveal<any>>>;
|
|
27
29
|
/** The code predicates of its events, by event name. */
|
|
28
30
|
eventMatches?(): Record<string, EventMatch<any>>;
|
|
29
31
|
};
|
package/dist/reveal-service.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { badRequest, notFound } from "./problem.js";
|
|
1
|
+
import { HttpProblem, badRequest, notFound } from "./problem.js";
|
|
2
2
|
import { tenantScope } from "./tenant.js";
|
|
3
3
|
import { requestAccess, requireOperation } from "./request-access.js";
|
|
4
4
|
import { readTransaction } from "./transactions.js";
|
|
@@ -16,15 +16,76 @@ const revealedValue = (deps, model, field, record) => {
|
|
|
16
16
|
field: field.name
|
|
17
17
|
});
|
|
18
18
|
};
|
|
19
|
+
/** The refusal of a reveal that needs a strong sign-in more recent than the caller's, which the app answers by asking them to confirm it's them. */
|
|
20
|
+
const strongSignInRequired = (minutes) => new HttpProblem(403, "strong-sign-in-required", "Forbidden", `Confirm it's you with a passkey or a second step: this needs one in the last ${minutes} minutes.`, { minutes });
|
|
21
|
+
const requireStrongSignIn = async (session, minutes, now = /* @__PURE__ */ new Date()) => {
|
|
22
|
+
const at = session.signIn && !session.signIn.staff && !session.client ? await session.signIn.strongAt?.() : void 0;
|
|
23
|
+
if (at && now.getTime() - at.getTime() <= minutes * 6e4) return;
|
|
24
|
+
throw strongSignInRequired(minutes);
|
|
25
|
+
};
|
|
26
|
+
const computedRevealFor = async (access, name) => {
|
|
27
|
+
const reveals = access.entry.source.computedReveals ?? {};
|
|
28
|
+
const reveal = Object.hasOwn(reveals, name) ? reveals[name] : void 0;
|
|
29
|
+
if (!reveal?.read) return reveal;
|
|
30
|
+
const { session } = access;
|
|
31
|
+
return await reveal.read({
|
|
32
|
+
user: {
|
|
33
|
+
id: session.user.id,
|
|
34
|
+
roles: session.user.roles
|
|
35
|
+
},
|
|
36
|
+
operation: "read",
|
|
37
|
+
model: access.full,
|
|
38
|
+
...session.organization && { organization: session.organization }
|
|
39
|
+
}) === true ? reveal : void 0;
|
|
40
|
+
};
|
|
41
|
+
const publishReveal = async (deps, entry, session, request, field) => {
|
|
42
|
+
const tenant = tenantScope(entry, session)?.tenantValue;
|
|
43
|
+
await deps.audit.publish({
|
|
44
|
+
type: "field.revealed",
|
|
45
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
46
|
+
actor: {
|
|
47
|
+
id: session.user.id,
|
|
48
|
+
roles: [...session.user.roles]
|
|
49
|
+
},
|
|
50
|
+
...tenant !== void 0 && { tenant },
|
|
51
|
+
resource: entry.name,
|
|
52
|
+
recordKey: encodeKey(request.key),
|
|
53
|
+
field,
|
|
54
|
+
origin: request.origin
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const revealComputed = async (access, reveal, request, name) => {
|
|
58
|
+
const { deps, entry, session } = access;
|
|
59
|
+
const fields = Object.values(access.model.fields);
|
|
60
|
+
const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields }));
|
|
61
|
+
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
62
|
+
if (reveal.strongSignIn) await requireStrongSignIn(session, reveal.strongSignIn.minutes ?? 15);
|
|
63
|
+
const record = Object.fromEntries(fields.map((field) => [field.name, found.record[field.name] ?? null]));
|
|
64
|
+
const value = await reveal.value(record, {
|
|
65
|
+
user: {
|
|
66
|
+
id: session.user.id,
|
|
67
|
+
roles: session.user.roles
|
|
68
|
+
},
|
|
69
|
+
...session.organization && { organization: session.organization }
|
|
70
|
+
});
|
|
71
|
+
await publishReveal(deps, entry, session, request, name);
|
|
72
|
+
return {
|
|
73
|
+
field: name,
|
|
74
|
+
value: value ?? null
|
|
75
|
+
};
|
|
76
|
+
};
|
|
19
77
|
/**
|
|
20
|
-
* The value of one sensitive field of one record,
|
|
21
|
-
* row filter and record-level rule as a get.
|
|
22
|
-
*
|
|
78
|
+
* The value of one sensitive field of one record, or of one of its computed reveals (`.reveals()`), for a caller who may
|
|
79
|
+
* read the record and the field: the same tenant, row filter and record-level rule as a get. A field or reveal that
|
|
80
|
+
* needs a recent strong sign-in is refused without one (`strongSignInRequired`). The reveal is published to the audit
|
|
81
|
+
* queue before the value is returned; when publishing fails, so does the reveal.
|
|
23
82
|
*/
|
|
24
83
|
const revealField = async (deps, entry, session, request) => {
|
|
25
84
|
const access = await requestAccess(deps, entry, session);
|
|
26
85
|
requireOperation(access, "read");
|
|
27
86
|
const name = typeof request.field === "string" ? request.field : "";
|
|
87
|
+
const computed = await computedRevealFor(access, name);
|
|
88
|
+
if (computed) return revealComputed(access, computed, request, name);
|
|
28
89
|
const field = Object.hasOwn(access.readable.fields, name) ? access.readable.fields[name] : void 0;
|
|
29
90
|
if (!field) throw badRequest("invalid-parameter", `Unknown field "${name}"`);
|
|
30
91
|
if (!field.sensitive) throw badRequest("invalid-parameter", `"${name}" is not sensitive; it comes with the record`);
|
|
@@ -33,25 +94,13 @@ const revealField = async (deps, entry, session, request) => {
|
|
|
33
94
|
const keyFields = [...full.primaryKey, ...tenantField].map((part) => full.fields[part]);
|
|
34
95
|
const found = await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => fetchReadable(trx, access, request.key, "hide", { fields: [...keyFields, full.fields[name]] }));
|
|
35
96
|
if (!found) throw notFound(`No ${entry.name} with this key`);
|
|
97
|
+
if (field.strongSignIn) await requireStrongSignIn(session, field.strongSignIn.minutes);
|
|
36
98
|
const value = revealedValue(deps, access.full, full.fields[name], found.record);
|
|
37
|
-
|
|
38
|
-
await deps.audit.publish({
|
|
39
|
-
type: "field.revealed",
|
|
40
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
41
|
-
actor: {
|
|
42
|
-
id: session.user.id,
|
|
43
|
-
roles: [...session.user.roles]
|
|
44
|
-
},
|
|
45
|
-
...tenant !== void 0 && { tenant },
|
|
46
|
-
resource: entry.name,
|
|
47
|
-
recordKey: encodeKey(request.key),
|
|
48
|
-
field: name,
|
|
49
|
-
origin: request.origin
|
|
50
|
-
});
|
|
99
|
+
await publishReveal(deps, entry, session, request, name);
|
|
51
100
|
return {
|
|
52
101
|
field: name,
|
|
53
102
|
value
|
|
54
103
|
};
|
|
55
104
|
};
|
|
56
105
|
//#endregion
|
|
57
|
-
export { revealField };
|
|
106
|
+
export { revealField, strongSignInRequired };
|
package/dist/types.d.ts
CHANGED
|
@@ -38,11 +38,13 @@ export type Session = {
|
|
|
38
38
|
/**
|
|
39
39
|
* The user's own sign-in this request comes with: its session id, when it has one, and whether staff started it as
|
|
40
40
|
* the user. An authenticator sets it only when it knows; without it, what only the user may do, such as registering
|
|
41
|
-
* their devices for notifications, is refused.
|
|
41
|
+
* their devices for notifications, is refused. `strongAt` reads when the sign-in last proved a passkey or a second
|
|
42
|
+
* step, `undefined` when it never did; without it, a reveal that needs a recent strong sign-in is refused.
|
|
42
43
|
*/
|
|
43
44
|
signIn?: {
|
|
44
45
|
session?: string;
|
|
45
46
|
staff: boolean;
|
|
47
|
+
strongAt?: () => Promise<Date | undefined>;
|
|
46
48
|
};
|
|
47
49
|
};
|
|
48
50
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.9",
|
|
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.
|
|
40
|
-
"@protobase/query": "0.46.
|
|
41
|
-
"@protobase/schema": "0.46.
|
|
39
|
+
"@protobase/layout": "0.46.9",
|
|
40
|
+
"@protobase/query": "0.46.9",
|
|
41
|
+
"@protobase/schema": "0.46.9",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|