@protobase/server 0.66.7 → 0.66.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/auth-mail/auth-emails.d.ts +1 -0
- package/dist/better-auth/auth-mail/auth-emails.js +2 -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-settings.d.ts +72 -0
- 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 +85 -13
- package/dist/better-auth/create-auth.js +20 -2
- package/dist/better-auth/recent-confirmation.js +58 -0
- package/dist/better-auth/staff-checks.js +2 -0
- package/dist/better-auth/strong-sign-ins.js +2 -2
- package/dist/package.js +1 -1
- package/dist/reveal-service.js +1 -1
- package/package.json +4 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { accountExistsEmail } from "./account-exists-email.js";
|
|
2
2
|
import { changeEmailCodeEmail } from "./change-email-code-email.js";
|
|
3
|
+
import { confirmationCodeEmail } from "./confirmation-code-email.js";
|
|
3
4
|
import { invitationEmail } from "./invitation-email.js";
|
|
4
5
|
import { noAccountEmail } from "./no-account-email.js";
|
|
5
6
|
import { passwordChangedEmail } from "./password-changed-email.js";
|
|
@@ -8,4 +9,4 @@ import { signInCodeEmail } from "./sign-in-code-email.js";
|
|
|
8
9
|
import { signUpCodeEmail } from "./sign-up-code-email.js";
|
|
9
10
|
import { staffSignInEmail } from "./staff-sign-in-email.js";
|
|
10
11
|
import { twoFactorCodeEmail } from "./two-factor-code-email.js";
|
|
11
|
-
export { accountExistsEmail, changeEmailCodeEmail, invitationEmail, noAccountEmail, passwordChangedEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
|
|
12
|
+
export { accountExistsEmail, changeEmailCodeEmail, confirmationCodeEmail, invitationEmail, noAccountEmail, passwordChangedEmail, resetPasswordEmail, signInCodeEmail, signUpCodeEmail, staffSignInEmail, twoFactorCodeEmail };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "../../index.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineEmail } from "../../mail/define-email.js";
|
|
2
|
+
import { EmailCode, EmailLayout, EmailNote } from "../../mail/email-layout.js";
|
|
3
|
+
import { Text } from "react-email";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/better-auth/auth-mail/confirmation-code-email.tsx
|
|
6
|
+
const ConfirmationCodeEmail = ({ app, code, lifetime }) => /* @__PURE__ */ jsxs(EmailLayout, {
|
|
7
|
+
app,
|
|
8
|
+
preview: `Your code to confirm it's you in ${app.name}`,
|
|
9
|
+
heading: "Confirm it's you",
|
|
10
|
+
children: [
|
|
11
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
12
|
+
"You are signed in to ",
|
|
13
|
+
app.name,
|
|
14
|
+
" and asked to confirm it's you. Use this code there:"
|
|
15
|
+
] }),
|
|
16
|
+
/* @__PURE__ */ jsx(EmailCode, { code }),
|
|
17
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
18
|
+
"It works for ",
|
|
19
|
+
lifetime,
|
|
20
|
+
", once, and only where you asked for it. It does not sign you in."
|
|
21
|
+
] }),
|
|
22
|
+
/* @__PURE__ */ jsx(EmailNote, { children: "If you did not ask for it, someone may be signed in to your account: sign out of your other devices on your account page." })
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
/** The email with a code that confirms it's them inside a signed-in session, for an account without a passkey or a second step. */
|
|
26
|
+
const confirmationCodeEmail = defineEmail({
|
|
27
|
+
name: "confirmation-code",
|
|
28
|
+
subject: ({ app, code }) => `${code} confirms it's you in ${app.name}`,
|
|
29
|
+
component: ConfirmationCodeEmail
|
|
30
|
+
});
|
|
31
|
+
//#endregion
|
|
32
|
+
export { confirmationCodeEmail };
|
|
@@ -146,6 +146,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
146
146
|
};
|
|
147
147
|
};
|
|
148
148
|
session: {
|
|
149
|
+
freshAge: number;
|
|
149
150
|
fields: {
|
|
150
151
|
expiresAt: string;
|
|
151
152
|
createdAt: string;
|
|
@@ -2570,6 +2571,14 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
2570
2571
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2571
2572
|
}[];
|
|
2572
2573
|
};
|
|
2574
|
+
} | {
|
|
2575
|
+
id: "protobase-recent-confirmation";
|
|
2576
|
+
hooks: {
|
|
2577
|
+
before: {
|
|
2578
|
+
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
2579
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2580
|
+
}[];
|
|
2581
|
+
};
|
|
2573
2582
|
} | {
|
|
2574
2583
|
id: "email-otp";
|
|
2575
2584
|
version: string;
|
|
@@ -3110,6 +3119,69 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
|
|
|
3110
3119
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
3111
3120
|
}[];
|
|
3112
3121
|
};
|
|
3122
|
+
} | {
|
|
3123
|
+
id: "protobase-confirmation-code";
|
|
3124
|
+
endpoints: {
|
|
3125
|
+
sendConfirmationCode: import("better-call").StrictEndpoint<string, {
|
|
3126
|
+
method: "POST";
|
|
3127
|
+
use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
3128
|
+
session: {
|
|
3129
|
+
session: Record<string, any> & {
|
|
3130
|
+
id: string;
|
|
3131
|
+
createdAt: Date;
|
|
3132
|
+
updatedAt: Date;
|
|
3133
|
+
userId: string;
|
|
3134
|
+
expiresAt: Date;
|
|
3135
|
+
token: string;
|
|
3136
|
+
ipAddress?: string | null | undefined;
|
|
3137
|
+
userAgent?: string | null | undefined;
|
|
3138
|
+
};
|
|
3139
|
+
user: Record<string, any> & {
|
|
3140
|
+
id: string;
|
|
3141
|
+
createdAt: Date;
|
|
3142
|
+
updatedAt: Date;
|
|
3143
|
+
email: string;
|
|
3144
|
+
emailVerified: boolean;
|
|
3145
|
+
name: string;
|
|
3146
|
+
image?: string | null | undefined;
|
|
3147
|
+
};
|
|
3148
|
+
};
|
|
3149
|
+
}>>[];
|
|
3150
|
+
}, {
|
|
3151
|
+
status: boolean;
|
|
3152
|
+
}>;
|
|
3153
|
+
verifyConfirmationCode: import("better-call").StrictEndpoint<string, {
|
|
3154
|
+
method: "POST";
|
|
3155
|
+
use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
3156
|
+
session: {
|
|
3157
|
+
session: Record<string, any> & {
|
|
3158
|
+
id: string;
|
|
3159
|
+
createdAt: Date;
|
|
3160
|
+
updatedAt: Date;
|
|
3161
|
+
userId: string;
|
|
3162
|
+
expiresAt: Date;
|
|
3163
|
+
token: string;
|
|
3164
|
+
ipAddress?: string | null | undefined;
|
|
3165
|
+
userAgent?: string | null | undefined;
|
|
3166
|
+
};
|
|
3167
|
+
user: Record<string, any> & {
|
|
3168
|
+
id: string;
|
|
3169
|
+
createdAt: Date;
|
|
3170
|
+
updatedAt: Date;
|
|
3171
|
+
email: string;
|
|
3172
|
+
emailVerified: boolean;
|
|
3173
|
+
name: string;
|
|
3174
|
+
image?: string | null | undefined;
|
|
3175
|
+
};
|
|
3176
|
+
};
|
|
3177
|
+
}>>[];
|
|
3178
|
+
body: import("zod").ZodObject<{
|
|
3179
|
+
code: import("zod").ZodString;
|
|
3180
|
+
}, import("zod/v4/core").$strip>;
|
|
3181
|
+
}, {
|
|
3182
|
+
status: boolean;
|
|
3183
|
+
}>;
|
|
3184
|
+
};
|
|
3113
3185
|
} | {
|
|
3114
3186
|
id: "protobase-sign-up";
|
|
3115
3187
|
endpoints: {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import "./auth-mail/send-auth-email.js";
|
|
2
|
+
import "zod";
|
|
3
|
+
//#region src/better-auth/confirmation-code-plugin.d.ts
|
|
4
|
+
/** The endpoints of the emailed confirmation code, as Better Auth's `ctx.path` names them. */
|
|
5
|
+
export declare const confirmationCodePaths: {
|
|
6
|
+
send: string;
|
|
7
|
+
verify: string;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { sendAuthEmail } from "./auth-mail/send-auth-email.js";
|
|
2
|
+
import { codeLifetime } from "./sign-in-mail.js";
|
|
3
|
+
import { saveStrongSignIn } from "./strong-sign-ins.js";
|
|
4
|
+
import { checkConfirmationCode, dropConfirmationCode, newConfirmationCode, saveConfirmationCode } from "./confirmation-codes.js";
|
|
5
|
+
import * as z$1 from "zod";
|
|
6
|
+
import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
|
7
|
+
//#region src/better-auth/confirmation-code-plugin.ts
|
|
8
|
+
/** The endpoints of the emailed confirmation code, as Better Auth's `ctx.path` names them. */
|
|
9
|
+
const confirmationCodePaths = {
|
|
10
|
+
send: "/account/confirmation-code/send",
|
|
11
|
+
verify: "/account/confirmation-code/verify"
|
|
12
|
+
};
|
|
13
|
+
const notOffered = {
|
|
14
|
+
code: "CONFIRMATION_CODE_NOT_OFFERED",
|
|
15
|
+
message: "Confirm it's you with your passkey or your second step instead."
|
|
16
|
+
};
|
|
17
|
+
const refusals = {
|
|
18
|
+
wrong: {
|
|
19
|
+
status: "BAD_REQUEST",
|
|
20
|
+
code: "INVALID_CODE",
|
|
21
|
+
message: "The code is not right."
|
|
22
|
+
},
|
|
23
|
+
"too-many": {
|
|
24
|
+
status: "FORBIDDEN",
|
|
25
|
+
code: "TOO_MANY_ATTEMPTS",
|
|
26
|
+
message: "Too many wrong codes. Ask for a new one."
|
|
27
|
+
},
|
|
28
|
+
expired: {
|
|
29
|
+
status: "BAD_REQUEST",
|
|
30
|
+
code: "OTP_EXPIRED",
|
|
31
|
+
message: "The code has expired. Ask for a new one."
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const isOffered = async (context, { user }) => !user.twoFactorEnabled && await context.adapter.count({
|
|
35
|
+
model: "passkey",
|
|
36
|
+
where: [{
|
|
37
|
+
field: "userId",
|
|
38
|
+
value: user.id
|
|
39
|
+
}]
|
|
40
|
+
}) === 0;
|
|
41
|
+
/**
|
|
42
|
+
* An emailed code that confirms it's them inside a session someone is signed in with, for an account without a passkey
|
|
43
|
+
* or a second step, which has nothing stronger to confirm with: a right code notes a strong sign-in for that session
|
|
44
|
+
* (`saveStrongSignIn`), as a passkey or a second step does. The code is its own kind, never a sign-in code; it belongs
|
|
45
|
+
* to the session and user that asked for it, a new one ends the one before, and it stops working after a few wrong
|
|
46
|
+
* ones (`confirmationCodeTries`). When the email cannot be sent, the request fails and no code is kept. The endpoints
|
|
47
|
+
* read the session cookie only, so an MCP client or an app's token cannot ask for one; staff sessions are refused by
|
|
48
|
+
* the staff plugin.
|
|
49
|
+
*/
|
|
50
|
+
const confirmationCodePlugin = (mail) => ({
|
|
51
|
+
id: "protobase-confirmation-code",
|
|
52
|
+
endpoints: {
|
|
53
|
+
/** `POST /account/confirmation-code/send`: emails the signed-in person a new confirmation code. */
|
|
54
|
+
sendConfirmationCode: createAuthEndpoint(confirmationCodePaths.send, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
use: [sessionMiddleware]
|
|
57
|
+
}, async (ctx) => {
|
|
58
|
+
const signed = ctx.context.session;
|
|
59
|
+
if (!await isOffered(ctx.context, signed)) throw new APIError("FORBIDDEN", notOffered);
|
|
60
|
+
const holder = {
|
|
61
|
+
userId: signed.user.id,
|
|
62
|
+
sessionId: signed.session.id
|
|
63
|
+
};
|
|
64
|
+
await dropConfirmationCode(ctx.context.internalAdapter, holder.sessionId);
|
|
65
|
+
const code = newConfirmationCode();
|
|
66
|
+
await sendAuthEmail(mail, "confirmationCodeEmail", signed.user.email, {
|
|
67
|
+
code,
|
|
68
|
+
lifetime: codeLifetime
|
|
69
|
+
}, ctx.context.baseURL);
|
|
70
|
+
await saveConfirmationCode(ctx.context.internalAdapter, holder, code);
|
|
71
|
+
return ctx.json({ status: true });
|
|
72
|
+
}),
|
|
73
|
+
/** `POST /account/confirmation-code/verify` with `{ code }`: confirms the session with the code it was emailed. */
|
|
74
|
+
verifyConfirmationCode: createAuthEndpoint(confirmationCodePaths.verify, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
use: [sessionMiddleware],
|
|
77
|
+
body: z$1.object({ code: z$1.string() })
|
|
78
|
+
}, async (ctx) => {
|
|
79
|
+
const signed = ctx.context.session;
|
|
80
|
+
if (!await isOffered(ctx.context, signed)) throw new APIError("FORBIDDEN", notOffered);
|
|
81
|
+
const checked = await checkConfirmationCode(ctx.context.internalAdapter, {
|
|
82
|
+
userId: signed.user.id,
|
|
83
|
+
sessionId: signed.session.id
|
|
84
|
+
}, ctx.body.code.trim());
|
|
85
|
+
if (checked !== "confirmed") {
|
|
86
|
+
const { status, ...refusal } = refusals[checked];
|
|
87
|
+
throw new APIError(status, refusal);
|
|
88
|
+
}
|
|
89
|
+
await saveStrongSignIn(ctx.context.internalAdapter, signed.session);
|
|
90
|
+
return ctx.json({ status: true });
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
//#endregion
|
|
95
|
+
export { confirmationCodePaths, confirmationCodePlugin };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { emailCodeMinutes } from "@protobase/schema/internal";
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
4
|
+
const identifierOf = (sessionId) => `protobase-confirmation-code-${sessionId}`;
|
|
5
|
+
const hashOf = (code) => createHash("sha256").update(code).digest("hex");
|
|
6
|
+
const sameHash = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
7
|
+
/** Ends the session's confirmation code, if it has one, so only a newer one works. */
|
|
8
|
+
const dropConfirmationCode = (internal, sessionId) => internal.deleteVerificationByIdentifier(identifierOf(sessionId));
|
|
9
|
+
/** A new six-digit code, as the email shows it. */
|
|
10
|
+
const newConfirmationCode = () => generateRandomString(6, "0-9");
|
|
11
|
+
/** Keeps `code` as the session's confirmation code for `emailCodeMinutes`, in place of any earlier one. */
|
|
12
|
+
const saveConfirmationCode = async (internal, holder, code, now = /* @__PURE__ */ new Date()) => {
|
|
13
|
+
const identifier = identifierOf(holder.sessionId);
|
|
14
|
+
await internal.deleteVerificationByIdentifier(identifier);
|
|
15
|
+
const value = {
|
|
16
|
+
userId: holder.userId,
|
|
17
|
+
hash: hashOf(code),
|
|
18
|
+
wrong: 0
|
|
19
|
+
};
|
|
20
|
+
await internal.createVerificationValue({
|
|
21
|
+
identifier,
|
|
22
|
+
value: JSON.stringify(value),
|
|
23
|
+
expiresAt: new Date(now.getTime() + emailCodeMinutes * 6e4)
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Checks `code` against the session's confirmation code: a code of another session or user is none. A right code is
|
|
28
|
+
* used up; a wrong one counts, and the code stops working at `confirmationCodeTries` wrong ones.
|
|
29
|
+
*/
|
|
30
|
+
const checkConfirmationCode = async (internal, holder, code, now = /* @__PURE__ */ new Date()) => {
|
|
31
|
+
const identifier = identifierOf(holder.sessionId);
|
|
32
|
+
const found = await internal.findVerificationValue(identifier);
|
|
33
|
+
if (!found || new Date(found.expiresAt) <= now) return "expired";
|
|
34
|
+
const stored = JSON.parse(found.value);
|
|
35
|
+
if (stored.userId !== holder.userId) return "expired";
|
|
36
|
+
if (sameHash(stored.hash, hashOf(code))) {
|
|
37
|
+
await internal.deleteVerificationByIdentifier(identifier);
|
|
38
|
+
return "confirmed";
|
|
39
|
+
}
|
|
40
|
+
await internal.deleteVerificationByIdentifier(identifier);
|
|
41
|
+
const wrong = stored.wrong + 1;
|
|
42
|
+
if (wrong >= 3) return "too-many";
|
|
43
|
+
await internal.createVerificationValue({
|
|
44
|
+
identifier,
|
|
45
|
+
value: JSON.stringify({
|
|
46
|
+
...stored,
|
|
47
|
+
wrong
|
|
48
|
+
}),
|
|
49
|
+
expiresAt: new Date(found.expiresAt)
|
|
50
|
+
});
|
|
51
|
+
return "wrong";
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { checkConfirmationCode, dropConfirmationCode, newConfirmationCode, saveConfirmationCode };
|
|
@@ -22,6 +22,7 @@ import { SsoOptions } from "./saml-sign-in.js";
|
|
|
22
22
|
import { samlPlugin } from "./saml-plugin.js";
|
|
23
23
|
import { signUpPaths } from "./sign-up/sign-up-plugin.js";
|
|
24
24
|
import { PasswordHash } from "./password-hashes.js";
|
|
25
|
+
import { confirmationCodePaths } from "./confirmation-code-plugin.js";
|
|
25
26
|
import { BetterAuthOptions } from "better-auth";
|
|
26
27
|
import { JwtOptions } from "better-auth/plugins";
|
|
27
28
|
//#region src/better-auth/create-auth.d.ts
|
|
@@ -278,6 +279,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
278
279
|
};
|
|
279
280
|
};
|
|
280
281
|
session: {
|
|
282
|
+
freshAge: number;
|
|
281
283
|
fields: {
|
|
282
284
|
expiresAt: string;
|
|
283
285
|
createdAt: string;
|
|
@@ -2702,6 +2704,14 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
2702
2704
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2703
2705
|
}[];
|
|
2704
2706
|
};
|
|
2707
|
+
} | {
|
|
2708
|
+
id: "protobase-recent-confirmation";
|
|
2709
|
+
hooks: {
|
|
2710
|
+
before: {
|
|
2711
|
+
matcher: (context: import("better-auth").HookEndpointContext) => boolean;
|
|
2712
|
+
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
2713
|
+
}[];
|
|
2714
|
+
};
|
|
2705
2715
|
} | {
|
|
2706
2716
|
id: "email-otp";
|
|
2707
2717
|
version: string;
|
|
@@ -2914,10 +2924,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
2914
2924
|
email: string;
|
|
2915
2925
|
emailVerified: boolean;
|
|
2916
2926
|
name: string;
|
|
2917
|
-
image
|
|
2918
|
-
* The secrets the app had before `secret`, newest first, such as `PROTOBASE_PREVIOUS_SECRETS`: what they encrypted
|
|
2919
|
-
* still decrypts, so the signing keys keep signing after the secret changes. Default none.
|
|
2920
|
-
*/ : string | null | undefined;
|
|
2927
|
+
image?: string | null | undefined;
|
|
2921
2928
|
} & Record<string, any>;
|
|
2922
2929
|
} | {
|
|
2923
2930
|
status: boolean;
|
|
@@ -3102,11 +3109,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
3102
3109
|
email: string;
|
|
3103
3110
|
emailVerified: boolean;
|
|
3104
3111
|
name: string;
|
|
3105
|
-
image
|
|
3106
|
-
* Refuses a new password that appears in a known data breach, by Have I Been Pwned's password list: at sign-up, from
|
|
3107
|
-
* an invitation, and when a password is set, changed, reset or given by an admin. Only the first five characters of
|
|
3108
|
-
* the password's SHA-1 hash leave the server. While the list cannot be reached, setting a password fails. Default off.
|
|
3109
|
-
*/ : string | null | undefined;
|
|
3112
|
+
image?: string | null | undefined;
|
|
3110
3113
|
};
|
|
3111
3114
|
};
|
|
3112
3115
|
}>>[];
|
|
@@ -3249,6 +3252,69 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
3249
3252
|
handler: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<void>>;
|
|
3250
3253
|
}[];
|
|
3251
3254
|
};
|
|
3255
|
+
} | {
|
|
3256
|
+
id: "protobase-confirmation-code";
|
|
3257
|
+
endpoints: {
|
|
3258
|
+
sendConfirmationCode: import("better-call").StrictEndpoint<string, {
|
|
3259
|
+
method: "POST";
|
|
3260
|
+
use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
3261
|
+
session: {
|
|
3262
|
+
session: Record<string, any> & {
|
|
3263
|
+
id: string;
|
|
3264
|
+
createdAt: Date;
|
|
3265
|
+
updatedAt: Date;
|
|
3266
|
+
userId: string;
|
|
3267
|
+
expiresAt: Date;
|
|
3268
|
+
token: string;
|
|
3269
|
+
ipAddress?: string | null | undefined;
|
|
3270
|
+
userAgent?: string | null | undefined;
|
|
3271
|
+
};
|
|
3272
|
+
user: Record<string, any> & {
|
|
3273
|
+
id: string;
|
|
3274
|
+
createdAt: Date;
|
|
3275
|
+
updatedAt: Date;
|
|
3276
|
+
email: string;
|
|
3277
|
+
emailVerified: boolean;
|
|
3278
|
+
name: string;
|
|
3279
|
+
image?: string | null | undefined;
|
|
3280
|
+
};
|
|
3281
|
+
};
|
|
3282
|
+
}>>[];
|
|
3283
|
+
}, {
|
|
3284
|
+
status: boolean;
|
|
3285
|
+
}>;
|
|
3286
|
+
verifyConfirmationCode: import("better-call").StrictEndpoint<string, {
|
|
3287
|
+
method: "POST";
|
|
3288
|
+
use: import("better-call").Middleware<import("better-call").MiddlewareOptions, (inputContext: import("better-call").MiddlewareInputContext<import("better-call").MiddlewareOptions>) => Promise<{
|
|
3289
|
+
session: {
|
|
3290
|
+
session: Record<string, any> & {
|
|
3291
|
+
id: string;
|
|
3292
|
+
createdAt: Date;
|
|
3293
|
+
updatedAt: Date;
|
|
3294
|
+
userId: string;
|
|
3295
|
+
expiresAt: Date;
|
|
3296
|
+
token: string;
|
|
3297
|
+
ipAddress?: string | null | undefined;
|
|
3298
|
+
userAgent?: string | null | undefined;
|
|
3299
|
+
};
|
|
3300
|
+
user: Record<string, any> & {
|
|
3301
|
+
id: string;
|
|
3302
|
+
createdAt: Date;
|
|
3303
|
+
updatedAt: Date;
|
|
3304
|
+
email: string;
|
|
3305
|
+
emailVerified: boolean;
|
|
3306
|
+
name: string;
|
|
3307
|
+
image?: string | null | undefined;
|
|
3308
|
+
};
|
|
3309
|
+
};
|
|
3310
|
+
}>>[];
|
|
3311
|
+
body: import("zod").ZodObject<{
|
|
3312
|
+
code: import("zod").ZodString;
|
|
3313
|
+
}, import("zod/v4/core").$strip>;
|
|
3314
|
+
}, {
|
|
3315
|
+
status: boolean;
|
|
3316
|
+
}>;
|
|
3317
|
+
};
|
|
3252
3318
|
} | {
|
|
3253
3319
|
id: "protobase-sign-up";
|
|
3254
3320
|
endpoints: {
|
|
@@ -4124,9 +4190,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
4124
4190
|
expiresAt: Date;
|
|
4125
4191
|
token: string;
|
|
4126
4192
|
ipAddress?: string | null | undefined;
|
|
4127
|
-
userAgent
|
|
4128
|
-
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
|
|
4129
|
-
/** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */ : string | null | undefined;
|
|
4193
|
+
userAgent?: string | null | undefined;
|
|
4130
4194
|
};
|
|
4131
4195
|
user: Record<string, any> & {
|
|
4132
4196
|
id: string;
|
|
@@ -5134,7 +5198,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
5134
5198
|
member: import("better-auth/plugins").Member & Record<string, any>;
|
|
5135
5199
|
}) => Promise<void | {
|
|
5136
5200
|
data: {
|
|
5137
|
-
name
|
|
5201
|
+
name?: string;
|
|
5138
5202
|
slug?: string;
|
|
5139
5203
|
logo?: string | null;
|
|
5140
5204
|
metadata?: Record<string, any>;
|
|
@@ -8940,6 +9004,14 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
|
|
|
8940
9004
|
window: number;
|
|
8941
9005
|
max: number;
|
|
8942
9006
|
};
|
|
9007
|
+
[confirmationCodePaths.send]: {
|
|
9008
|
+
window: number;
|
|
9009
|
+
max: number;
|
|
9010
|
+
};
|
|
9011
|
+
[confirmationCodePaths.verify]: {
|
|
9012
|
+
window: number;
|
|
9013
|
+
max: number;
|
|
9014
|
+
};
|
|
8943
9015
|
'/sign-in/email': {
|
|
8944
9016
|
window: number;
|
|
8945
9017
|
max: number;
|
|
@@ -50,6 +50,8 @@ import { confirmedOnCreate, confirmedOnUpdate, emailConfirmedField } from "./ema
|
|
|
50
50
|
import { rehashPasswordsPlugin, verifyAcceptedHash } from "./password-hashes.js";
|
|
51
51
|
import { staffSignInPlugin } from "./staff-plugin.js";
|
|
52
52
|
import { strongSignInsPlugin } from "./strong-sign-ins.js";
|
|
53
|
+
import { recentConfirmationPlugin } from "./recent-confirmation.js";
|
|
54
|
+
import { confirmationCodePaths, confirmationCodePlugin } from "./confirmation-code-plugin.js";
|
|
53
55
|
import { tokenClaims } from "./token-claims.js";
|
|
54
56
|
import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
|
|
55
57
|
import { metadataField, userFieldsNamed, userModel, userTable, withUserColumns } from "./user-table.js";
|
|
@@ -191,7 +193,10 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
191
193
|
...Object.keys(userFields).length > 0 && { additionalFields: userFields },
|
|
192
194
|
...deleteUser
|
|
193
195
|
},
|
|
194
|
-
session:
|
|
196
|
+
session: {
|
|
197
|
+
...coreNames.session,
|
|
198
|
+
freshAge: 0
|
|
199
|
+
},
|
|
195
200
|
verification: coreNames.verification,
|
|
196
201
|
account: {
|
|
197
202
|
...coreNames.account,
|
|
@@ -219,7 +224,12 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
219
224
|
origins: appOrigins(options.baseURL, origins)
|
|
220
225
|
}),
|
|
221
226
|
strongSignInsPlugin(),
|
|
222
|
-
|
|
227
|
+
recentConfirmationPlugin(),
|
|
228
|
+
...mail ? [
|
|
229
|
+
emailOTP(emailCodeOptions(mail)),
|
|
230
|
+
noAccountMailPlugin(mail),
|
|
231
|
+
confirmationCodePlugin(mail)
|
|
232
|
+
] : [],
|
|
223
233
|
...mail && signUpRole ? [signUpPlugin({
|
|
224
234
|
role: signUpRole,
|
|
225
235
|
mail,
|
|
@@ -343,6 +353,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
343
353
|
[signUpPaths.create]: {
|
|
344
354
|
window: 60,
|
|
345
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
|
|
346
364
|
}
|
|
347
365
|
}
|
|
348
366
|
},
|
|
@@ -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 };
|
|
@@ -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;
|
package/dist/package.js
CHANGED
package/dist/reveal-service.js
CHANGED
|
@@ -17,7 +17,7 @@ const revealedValue = (deps, model, field, record) => {
|
|
|
17
17
|
});
|
|
18
18
|
};
|
|
19
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
|
|
20
|
+
const strongSignInRequired = (minutes) => new HttpProblem(403, "strong-sign-in-required", "Forbidden", `Confirm it's you with a passkey, a second step or an emailed code: this needs one in the last ${minutes} minutes.`, { minutes });
|
|
21
21
|
const requireStrongSignIn = async (session, minutes, now = /* @__PURE__ */ new Date()) => {
|
|
22
22
|
const at = session.signIn && !session.signIn.staff && !session.client ? await session.signIn.strongAt?.() : void 0;
|
|
23
23
|
if (at && now.getTime() - at.getTime() <= minutes * 6e4) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.66.
|
|
3
|
+
"version": "0.66.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": {
|
|
@@ -43,9 +43,9 @@
|
|
|
43
43
|
"@better-auth/sso": "^1.7.12",
|
|
44
44
|
"@livekit/protocol": "1.53.1",
|
|
45
45
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
46
|
-
"@protobase/layout": "0.66.
|
|
47
|
-
"@protobase/query": "0.66.
|
|
48
|
-
"@protobase/schema": "0.66.
|
|
46
|
+
"@protobase/layout": "0.66.9",
|
|
47
|
+
"@protobase/query": "0.66.9",
|
|
48
|
+
"@protobase/schema": "0.66.9",
|
|
49
49
|
"@react-email/render": "^2.1.0",
|
|
50
50
|
"@react-pdf/primitives": "^4.4.0",
|
|
51
51
|
"@react-pdf/renderer": "^4.9.0",
|