@protobase/server 0.8.2 → 0.10.0
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/active-role.js +8 -0
- package/dist/admin-options.d.ts +8 -0
- package/dist/api-cors.d.ts +5 -0
- package/dist/api-cors.js +53 -0
- package/dist/batch/run-batch.js +4 -2
- package/dist/better-auth/admin-only.js +5 -3
- package/dist/better-auth/authenticator.d.ts +1 -1
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +166 -160
- package/dist/better-auth/create-auth.js +40 -10
- package/dist/better-auth/operator-provider.d.ts +2 -1
- package/dist/better-auth/origins.d.ts +4 -0
- package/dist/better-auth/origins.js +43 -0
- package/dist/better-auth/passkey-domain.js +41 -0
- package/dist/better-auth/passkey-plugin.js +151 -0
- package/dist/better-auth/roles/active-role.js +26 -0
- package/dist/better-auth/roles/roles-plugin.js +73 -0
- package/dist/better-auth/roles/session-role.js +28 -0
- package/dist/better-auth/staff-sign-in-endpoints.js +9 -2
- package/dist/better-auth/status-route.js +5 -1
- package/dist/better-auth/tokens.d.ts +2 -1
- package/dist/better-auth/tokens.js +5 -2
- package/dist/changes/actor.js +7 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/capture-sql.js +13 -2
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +2 -1
- package/dist/changes/outbox.d.ts +14 -0
- package/dist/changes/outbox.js +51 -0
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +2 -1
- package/dist/check-shell.js +11 -2
- package/dist/create-admin.d.ts +13 -6
- package/dist/create-admin.js +42 -8
- package/dist/index.d.ts +13 -5
- package/dist/index.js +14 -8
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/current.js +3 -1
- package/dist/jobs/failed-jobs.js +10 -10
- package/dist/jobs/functions-sql.js +8 -5
- package/dist/jobs/runtime.js +9 -5
- package/dist/meta.js +9 -2
- package/dist/openapi/components.js +1 -1
- package/dist/openapi/document.js +7 -5
- package/dist/openapi/naming.js +2 -1
- package/dist/project-config.d.ts +4 -1
- package/dist/project-config.js +10 -2
- package/dist/resource-source.d.ts +9 -3
- package/dist/resource-source.js +8 -3
- package/dist/routes/docs.js +2 -2
- package/dist/types.d.ts +5 -1
- package/dist/webhooks/attempt.js +91 -0
- package/dist/webhooks/retry-schedule.js +40 -0
- package/dist/webhooks/route-events.js +61 -0
- package/dist/webhooks/standard-webhooks.d.ts +4 -0
- package/dist/webhooks/standard-webhooks.js +36 -0
- package/dist/webhooks/subscriber-view.js +76 -0
- package/dist/webhooks/subscription.d.ts +92 -0
- package/dist/webhooks/subscription.js +28 -0
- package/dist/webhooks/table/address-check.js +28 -0
- package/dist/webhooks/table/options.d.ts +23 -0
- package/dist/webhooks/table/options.js +14 -0
- package/dist/webhooks/table/owner-roles.js +29 -0
- package/dist/webhooks/table/plugin.d.ts +10 -0
- package/dist/webhooks/table/plugin.js +18 -0
- package/dist/webhooks/table/schema-sql.js +116 -0
- package/dist/webhooks/table/schema.d.ts +13 -0
- package/dist/webhooks/table/schema.js +44 -0
- package/dist/webhooks/table/table-subscriptions.js +77 -0
- package/dist/webhooks/table/validate-hook.js +32 -0
- package/dist/webhooks/table/webhook-table.js +23 -0
- package/dist/webhooks/test-event.js +48 -0
- package/dist/webhooks/webhooks.js +233 -0
- package/dist/write-pipeline.js +4 -2
- package/package.json +4 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { multiHostBaseURL, readOrigins, resolveOrigins } from "./origins.js";
|
|
1
2
|
import { readMailSettings } from "../mail/mail-settings.js";
|
|
2
3
|
import { smtpMailer } from "../mail/smtp-mailer.js";
|
|
3
4
|
import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
|
|
@@ -6,14 +7,17 @@ import { lastOrganizationField, organizationClaims, sessionOrganizationHooks } f
|
|
|
6
7
|
import { organizationPlugin } from "./organizations/organization-plugin.js";
|
|
7
8
|
import { protobaseOrganizationPlugin } from "./organizations/protobase-plugin.js";
|
|
8
9
|
import { resolveOrganizations } from "./organizations/options.js";
|
|
10
|
+
import { passkeyHosts, readPasskeyDomain, resolvePasskeyDomain } from "./passkey-domain.js";
|
|
11
|
+
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
12
|
+
import { passkeyPlugin } from "./passkey-plugin.js";
|
|
9
13
|
import { passwordResetOptions } from "./password-reset.js";
|
|
14
|
+
import { narrowClaims } from "./roles/active-role.js";
|
|
15
|
+
import { rolesPlugin } from "./roles/roles-plugin.js";
|
|
10
16
|
import { platformSignInFor, platformSignInProvider, readPlatformSignIn, resolvePlatformSignIn } from "./platform-sign-in.js";
|
|
11
17
|
import { signInPolicyPlugin } from "./policy-plugin.js";
|
|
12
18
|
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
13
|
-
import { coreNames, pluginNames } from "./snake-case-names.js";
|
|
14
19
|
import { staffSignInPlugin } from "./staff-plugin.js";
|
|
15
20
|
import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
|
|
16
|
-
import { passkey } from "@better-auth/passkey";
|
|
17
21
|
import { APIError } from "better-auth/api";
|
|
18
22
|
import { betterAuth } from "better-auth";
|
|
19
23
|
import { admin, emailOTP, genericOAuth, jwt } from "better-auth/plugins";
|
|
@@ -21,6 +25,8 @@ import { adminAc, userAc } from "better-auth/plugins/admin/access";
|
|
|
21
25
|
//#region src/better-auth/create-auth.ts
|
|
22
26
|
const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
|
|
23
27
|
const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
|
|
28
|
+
const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
|
|
29
|
+
const platformPasskeyDomain = () => readPasskeyDomain(globalThis.process?.env ?? {});
|
|
24
30
|
const platformMailer = () => {
|
|
25
31
|
const settings = readMailSettings(globalThis.process?.env ?? {});
|
|
26
32
|
return settings && smtpMailer(settings);
|
|
@@ -42,17 +48,19 @@ const organizationPaths = ["/organization/set-active", "/organization/accept-inv
|
|
|
42
48
|
* The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
|
|
43
49
|
* sign-in provider already resolved.
|
|
44
50
|
*/
|
|
45
|
-
const betterAuthOptions = (options, { roles, defaultRole, mailer, operator, platformSignIn, organizations, context = noContext }) => {
|
|
51
|
+
const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, definitions, defaultRole, mailer, operator, platformSignIn, organizations, context = noContext }) => {
|
|
46
52
|
const secure = options.baseURL.startsWith("https://");
|
|
47
53
|
const signIn = platformSignInFor(platformSignIn, Object.keys(options.socialProviders ?? {}));
|
|
54
|
+
const hosts = passkeyHosts([options.baseURL, ...origins]);
|
|
48
55
|
return {
|
|
49
56
|
appName: options.appName ?? "Protobase",
|
|
50
57
|
database: options.database,
|
|
51
|
-
baseURL: options.baseURL,
|
|
58
|
+
baseURL: multiHostBaseURL(options.baseURL, origins),
|
|
52
59
|
basePath: authBasePath,
|
|
53
60
|
secret: options.secret,
|
|
54
61
|
trustedOrigins: [
|
|
55
62
|
options.baseURL,
|
|
63
|
+
...origins,
|
|
56
64
|
"https://*.trycloudflare.com",
|
|
57
65
|
...options.trustedOrigins ?? []
|
|
58
66
|
],
|
|
@@ -86,11 +94,17 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator, plat
|
|
|
86
94
|
expirationTime: tokenLifetime,
|
|
87
95
|
issuer: options.baseURL,
|
|
88
96
|
audience: options.baseURL,
|
|
89
|
-
definePayload: async ({ user, session }) =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
definePayload: async ({ user, session }) => {
|
|
98
|
+
const { activeRole, activeOrganizationId } = session;
|
|
99
|
+
const claims = {
|
|
100
|
+
role: user.role,
|
|
101
|
+
...organizations && await organizationClaims((await context()).adapter, user, activeOrganizationId)
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
email: user.email,
|
|
105
|
+
...narrowClaims(claims, [activeRole, user.lastRole], Boolean(organizations))
|
|
106
|
+
};
|
|
107
|
+
}
|
|
94
108
|
},
|
|
95
109
|
disableSettingJwtHeader: true
|
|
96
110
|
}),
|
|
@@ -99,7 +113,11 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator, plat
|
|
|
99
113
|
allowPasswordless: true,
|
|
100
114
|
...mailer && { otpOptions: twoFactorCodeOptions(mailer) }
|
|
101
115
|
}),
|
|
102
|
-
|
|
116
|
+
passkeyPlugin({
|
|
117
|
+
domain: passkeyDomain ?? hosts[0],
|
|
118
|
+
hosts,
|
|
119
|
+
formerDomain: hosts[0]
|
|
120
|
+
}),
|
|
103
121
|
...mailer ? [emailOTP(emailCodeOptions(mailer))] : [],
|
|
104
122
|
...signIn ? [genericOAuth({ config: [platformSignInProvider(signIn)] })] : [],
|
|
105
123
|
signInPolicyPlugin({
|
|
@@ -115,6 +133,7 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer, operator, plat
|
|
|
115
133
|
...operator && { operator },
|
|
116
134
|
...mailer && { mailer }
|
|
117
135
|
}),
|
|
136
|
+
rolesPlugin({ definitions }),
|
|
118
137
|
...organizations ? [organizationPlugin({
|
|
119
138
|
resolved: organizations,
|
|
120
139
|
secret: options.secret,
|
|
@@ -186,11 +205,16 @@ const createAuth = (options) => {
|
|
|
186
205
|
const signInSettings = options.signInProvider === void 0 ? platformSignInSettings() : options.signInProvider || void 0;
|
|
187
206
|
const socialProviders = Object.keys(options.socialProviders ?? {});
|
|
188
207
|
const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
|
|
208
|
+
const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
|
|
209
|
+
const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
|
|
189
210
|
const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
|
|
190
211
|
let instance;
|
|
191
212
|
const context = () => instance?.$context ?? Promise.reject(/* @__PURE__ */ new Error("The auth instance is not made yet"));
|
|
192
213
|
const auth = betterAuth(betterAuthOptions(options, {
|
|
214
|
+
origins,
|
|
215
|
+
passkeyDomain,
|
|
193
216
|
roles,
|
|
217
|
+
definitions,
|
|
194
218
|
defaultRole,
|
|
195
219
|
mailer,
|
|
196
220
|
operator,
|
|
@@ -202,6 +226,12 @@ const createAuth = (options) => {
|
|
|
202
226
|
}));
|
|
203
227
|
instance = auth;
|
|
204
228
|
return Object.assign(auth, {
|
|
229
|
+
/** Issuer and audience of the tokens: `baseURL`, on every origin. */
|
|
230
|
+
issuer: options.baseURL,
|
|
231
|
+
/** Every origin the app is served on, `baseURL`'s first. */
|
|
232
|
+
origins: [new URL(options.baseURL).origin, ...origins],
|
|
233
|
+
/** The RP ID of new passkeys. */
|
|
234
|
+
passkeyDomain,
|
|
205
235
|
roles,
|
|
206
236
|
roleDefinitions: definitions,
|
|
207
237
|
organizations,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
//#region src/better-auth/operator-provider.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The identity provider the operator's staff sign in with to sign in as people of this app: any OpenID Connect
|
|
4
|
-
* provider, with this app registered as a confidential client whose redirect URI is `{
|
|
4
|
+
* provider, with this app registered as a confidential client whose redirect URI is `{origin}/api/auth/staff/callback`,
|
|
5
|
+
* for `baseURL` and each of the app's other `origins`.
|
|
5
6
|
*/
|
|
6
7
|
export type OperatorProvider = {
|
|
7
8
|
/** The provider's issuer URL; its settings are read from `{issuer}/.well-known/openid-configuration`. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/better-auth/origins.ts
|
|
2
|
+
const originsVariable = "PROTOBASE_ORIGINS";
|
|
3
|
+
/** The comma-separated list in an environment variable, blanks left out; empty when it is unset. */
|
|
4
|
+
const readList = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
5
|
+
/** The origins the platform passes in `PROTOBASE_ORIGINS`, comma separated; empty when it is unset. */
|
|
6
|
+
const readOrigins = (env) => readList(env[originsVariable]);
|
|
7
|
+
const originOf = (value, what) => {
|
|
8
|
+
if (!URL.canParse(value)) throw new Error(`${what} must be a URL such as https://admin.example.com, not "${value}"`);
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`${what} must be an http or https URL, not "${value}"`);
|
|
11
|
+
return url;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The origins the app is served on besides `baseURL`, as `https://host[:port]`, without `baseURL`'s own and without
|
|
15
|
+
* repeats. Each must be a URL with the scheme of `baseURL`, since Better Auth builds every host's URLs with one scheme.
|
|
16
|
+
*/
|
|
17
|
+
const resolveOrigins = (baseURL, origins) => {
|
|
18
|
+
const base = originOf(baseURL, "baseURL");
|
|
19
|
+
const resolved = origins.map((origin) => {
|
|
20
|
+
const url = originOf(origin, `The origin "${origin}"`);
|
|
21
|
+
if (url.protocol !== base.protocol) throw new Error(`The origin "${origin}" must use ${base.protocol.slice(0, -1)}, like baseURL`);
|
|
22
|
+
return url.origin;
|
|
23
|
+
});
|
|
24
|
+
return [...new Set(resolved)].filter((origin) => origin !== base.origin);
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Better Auth's `baseURL` for an app on several origins: each request is answered for its own host when that is one of
|
|
28
|
+
* them (its links, such as a password reset, and its passkey and sign-in callbacks then stay on that host), and for
|
|
29
|
+
* `baseURL` otherwise. With no other origins it is `baseURL` itself.
|
|
30
|
+
*/
|
|
31
|
+
const multiHostBaseURL = (baseURL, origins) => {
|
|
32
|
+
if (origins.length === 0) return baseURL;
|
|
33
|
+
const base = new URL(baseURL);
|
|
34
|
+
return {
|
|
35
|
+
allowedHosts: [base.host, ...origins.map((origin) => new URL(origin).host)],
|
|
36
|
+
fallback: baseURL,
|
|
37
|
+
protocol: base.protocol === "https:" ? "https" : "http"
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** Where browsers read which origins may use the app's passkeys (WebAuthn Related Origin Requests). */
|
|
41
|
+
const webauthnPath = "/.well-known/webauthn";
|
|
42
|
+
//#endregion
|
|
43
|
+
export { multiHostBaseURL, originsVariable, readList, readOrigins, resolveOrigins, webauthnPath };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/better-auth/passkey-domain.ts
|
|
2
|
+
const passkeyDomainVariable = "PROTOBASE_PASSKEY_DOMAIN";
|
|
3
|
+
/** The passkey domain the platform passes in `PROTOBASE_PASSKEY_DOMAIN`; `undefined` when it is unset. */
|
|
4
|
+
const readPasskeyDomain = (env) => env["PROTOBASE_PASSKEY_DOMAIN"]?.trim() || void 0;
|
|
5
|
+
/** The host names of the app's origins, which are the RP IDs its passkeys can have, without repeats. */
|
|
6
|
+
const passkeyHosts = (origins) => [...new Set(origins.map((origin) => new URL(origin).hostname))];
|
|
7
|
+
/**
|
|
8
|
+
* The RP ID new passkeys get: `domain` when given, which must be the host of one of the app's origins, and otherwise
|
|
9
|
+
* the host of the first, `baseURL`'s.
|
|
10
|
+
*/
|
|
11
|
+
const resolvePasskeyDomain = (domain, origins) => {
|
|
12
|
+
const hosts = passkeyHosts(origins);
|
|
13
|
+
if (domain === void 0) return hosts[0];
|
|
14
|
+
const host = domain.toLowerCase();
|
|
15
|
+
if (!hosts.includes(host)) throw new Error(`The passkey domain "${domain}" must be the host of one of the app's origins: ${hosts.join(", ")}`);
|
|
16
|
+
return host;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The RP IDs a passkey sign-in may ask the browser for, which asks for one at a time: the passkey domain, then each
|
|
20
|
+
* other host of the app that a passkey was made for (passkeys made before they kept their RP ID count for the host of
|
|
21
|
+
* `baseURL`, the first). On one origin that is the passkey domain alone, read without a query.
|
|
22
|
+
*/
|
|
23
|
+
const signInPasskeyDomains = async (adapter, domain, origins) => {
|
|
24
|
+
const hosts = passkeyHosts(origins);
|
|
25
|
+
const others = hosts.filter((host) => host !== domain);
|
|
26
|
+
if (others.length === 0) return [domain];
|
|
27
|
+
const held = await Promise.all(others.map(async (host) => {
|
|
28
|
+
const where = (host === hosts[0] ? [host, null] : [host]).map((value) => ({
|
|
29
|
+
field: "rpID",
|
|
30
|
+
value,
|
|
31
|
+
connector: "OR"
|
|
32
|
+
}));
|
|
33
|
+
return await adapter.count({
|
|
34
|
+
model: "passkey",
|
|
35
|
+
where
|
|
36
|
+
}) > 0;
|
|
37
|
+
}));
|
|
38
|
+
return [domain, ...others.filter((_, index) => held[index])];
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { passkeyDomainVariable, passkeyHosts, readPasskeyDomain, resolvePasskeyDomain, signInPasskeyDomains };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { pluginNames } from "./snake-case-names.js";
|
|
2
|
+
import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
|
|
3
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
4
|
+
import * as z$1 from "zod";
|
|
5
|
+
import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
|
|
6
|
+
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|
7
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
8
|
+
//#region src/better-auth/passkey-plugin.ts
|
|
9
|
+
const challengeCookie = "better-auth-passkey";
|
|
10
|
+
const challengeSeconds = 300;
|
|
11
|
+
const unknownDomain = {
|
|
12
|
+
code: "UNKNOWN_PASSKEY_DOMAIN",
|
|
13
|
+
message: "This app has no passkeys for that domain."
|
|
14
|
+
};
|
|
15
|
+
const decodeBase64 = (value) => Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
|
|
16
|
+
/**
|
|
17
|
+
* Better Auth's passkeys, with an RP ID kept per passkey. New passkeys are made for the passkey domain; each keeps the
|
|
18
|
+
* RP ID it was made with, so changing the passkey domain breaks none. A sign-in asks the browser for passkeys of one RP
|
|
19
|
+
* ID, `?rpId=` or the passkey domain, and checks the passkey it gets against its own. On an app with several origins,
|
|
20
|
+
* browsers let each origin use the passkeys of the others through `/.well-known/webauthn` (Related Origin Requests).
|
|
21
|
+
*/
|
|
22
|
+
const passkeyPlugin = ({ domain, hosts, formerDomain }) => {
|
|
23
|
+
const base = passkey({
|
|
24
|
+
schema: pluginNames.passkey,
|
|
25
|
+
rpID: domain
|
|
26
|
+
});
|
|
27
|
+
const rpIDOf = (stored) => stored.rpID ?? formerDomain;
|
|
28
|
+
/** `GET /passkey/generate-authenticate-options?rpId=`: what the browser needs to ask for a passkey of that RP ID. */
|
|
29
|
+
const generatePasskeyAuthenticationOptions = createAuthEndpoint("/passkey/generate-authenticate-options", {
|
|
30
|
+
method: "GET",
|
|
31
|
+
query: z$1.object({ rpId: z$1.string().optional() }).optional()
|
|
32
|
+
}, async (ctx) => {
|
|
33
|
+
const rpID = ctx.query?.rpId?.toLowerCase() ?? domain;
|
|
34
|
+
if (!hosts.includes(rpID)) throw new APIError("BAD_REQUEST", unknownDomain);
|
|
35
|
+
const session = await getSessionFromCtx(ctx);
|
|
36
|
+
const passkeys = (session ? await ctx.context.adapter.findMany({
|
|
37
|
+
model: "passkey",
|
|
38
|
+
where: [{
|
|
39
|
+
field: "userId",
|
|
40
|
+
value: session.user.id
|
|
41
|
+
}]
|
|
42
|
+
}) : []).filter((stored) => rpIDOf(stored) === rpID);
|
|
43
|
+
const options = await generateAuthenticationOptions({
|
|
44
|
+
rpID,
|
|
45
|
+
userVerification: "preferred",
|
|
46
|
+
...passkeys.length > 0 && { allowCredentials: passkeys.map((stored) => ({
|
|
47
|
+
id: stored.credentialID,
|
|
48
|
+
...stored.transports && { transports: stored.transports.split(",") }
|
|
49
|
+
})) }
|
|
50
|
+
});
|
|
51
|
+
const token = generateRandomString(32);
|
|
52
|
+
const cookie = ctx.context.createAuthCookie(challengeCookie);
|
|
53
|
+
await ctx.setSignedCookie(cookie.name, token, ctx.context.secret, {
|
|
54
|
+
...cookie.attributes,
|
|
55
|
+
maxAge: challengeSeconds
|
|
56
|
+
});
|
|
57
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
58
|
+
identifier: token,
|
|
59
|
+
value: JSON.stringify({
|
|
60
|
+
type: "authentication",
|
|
61
|
+
expectedChallenge: options.challenge,
|
|
62
|
+
userData: { id: session?.user.id ?? "" }
|
|
63
|
+
}),
|
|
64
|
+
expiresAt: new Date(Date.now() + challengeSeconds * 1e3)
|
|
65
|
+
});
|
|
66
|
+
return ctx.json(options);
|
|
67
|
+
});
|
|
68
|
+
/** `POST /passkey/verify-authentication`: checks the browser's answer against the passkey's own RP ID and signs in. */
|
|
69
|
+
const verifyPasskeyAuthentication = createAuthEndpoint("/passkey/verify-authentication", {
|
|
70
|
+
method: "POST",
|
|
71
|
+
body: z$1.object({ response: z$1.record(z$1.any(), z$1.any()) })
|
|
72
|
+
}, async (ctx) => {
|
|
73
|
+
const origin = ctx.headers?.get("origin");
|
|
74
|
+
if (!origin) throw new APIError("BAD_REQUEST", { message: "origin missing" });
|
|
75
|
+
const response = ctx.body.response;
|
|
76
|
+
const cookie = ctx.context.createAuthCookie(challengeCookie);
|
|
77
|
+
const token = await ctx.getSignedCookie(cookie.name, ctx.context.secret);
|
|
78
|
+
if (!token) throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
79
|
+
const challenge = await ctx.context.internalAdapter.consumeVerificationValue(token);
|
|
80
|
+
if (!challenge) throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
81
|
+
const { type, expectedChallenge } = JSON.parse(challenge.value);
|
|
82
|
+
if (type !== "authentication") throw new APIError("BAD_REQUEST", PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND);
|
|
83
|
+
const stored = await ctx.context.adapter.findOne({
|
|
84
|
+
model: "passkey",
|
|
85
|
+
where: [{
|
|
86
|
+
field: "credentialID",
|
|
87
|
+
value: response.id
|
|
88
|
+
}]
|
|
89
|
+
});
|
|
90
|
+
if (!stored) throw new APIError("UNAUTHORIZED", PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND);
|
|
91
|
+
const verification = await verifyAuthenticationResponse({
|
|
92
|
+
response,
|
|
93
|
+
expectedChallenge,
|
|
94
|
+
expectedOrigin: origin,
|
|
95
|
+
expectedRPID: rpIDOf(stored),
|
|
96
|
+
credential: {
|
|
97
|
+
id: stored.credentialID,
|
|
98
|
+
publicKey: decodeBase64(stored.publicKey),
|
|
99
|
+
counter: stored.counter,
|
|
100
|
+
...stored.transports && { transports: stored.transports.split(",") }
|
|
101
|
+
},
|
|
102
|
+
requireUserVerification: false
|
|
103
|
+
}).catch((error) => {
|
|
104
|
+
ctx.context.logger.error("Failed to verify the passkey", error);
|
|
105
|
+
});
|
|
106
|
+
if (!verification?.verified) throw new APIError("UNAUTHORIZED", PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED);
|
|
107
|
+
await ctx.context.adapter.update({
|
|
108
|
+
model: "passkey",
|
|
109
|
+
where: [{
|
|
110
|
+
field: "id",
|
|
111
|
+
value: stored.id
|
|
112
|
+
}],
|
|
113
|
+
update: { counter: verification.authenticationInfo.newCounter }
|
|
114
|
+
});
|
|
115
|
+
const session = await ctx.context.internalAdapter.createSession(stored.userId);
|
|
116
|
+
if (!session) throw new APIError("INTERNAL_SERVER_ERROR", PASSKEY_ERROR_CODES.UNABLE_TO_CREATE_SESSION);
|
|
117
|
+
const user = await ctx.context.internalAdapter.findUserById(stored.userId);
|
|
118
|
+
if (!user) throw new APIError("INTERNAL_SERVER_ERROR", PASSKEY_ERROR_CODES.USER_NOT_FOUND);
|
|
119
|
+
await setSessionCookie(ctx, {
|
|
120
|
+
session,
|
|
121
|
+
user
|
|
122
|
+
});
|
|
123
|
+
return ctx.json({
|
|
124
|
+
session,
|
|
125
|
+
user
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
endpoints: {
|
|
131
|
+
...base.endpoints,
|
|
132
|
+
generatePasskeyAuthenticationOptions,
|
|
133
|
+
verifyPasskeyAuthentication
|
|
134
|
+
},
|
|
135
|
+
schema: { passkey: {
|
|
136
|
+
...base.schema.passkey,
|
|
137
|
+
fields: {
|
|
138
|
+
...base.schema.passkey.fields,
|
|
139
|
+
rpID: {
|
|
140
|
+
type: "string",
|
|
141
|
+
required: false,
|
|
142
|
+
fieldName: "rp_id",
|
|
143
|
+
input: false,
|
|
144
|
+
defaultValue: () => domain
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} }
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
export { passkeyPlugin };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseRoles } from "../parse-roles.js";
|
|
2
|
+
import { noGlobalRole } from "../organizations/role-definitions.js";
|
|
3
|
+
import { globalRoles } from "../organizations/app-roles.js";
|
|
4
|
+
//#region src/better-auth/roles/active-role.ts
|
|
5
|
+
/**
|
|
6
|
+
* The one role someone acts with: the first of `wanted` they hold (the session's active role, then the one they last
|
|
7
|
+
* chose), else the first role they hold. `undefined` for someone without a role.
|
|
8
|
+
*/
|
|
9
|
+
const resolveActiveRole = (held, wanted) => wanted.find((name) => typeof name === "string" && held.includes(name)) ?? held[0];
|
|
10
|
+
/**
|
|
11
|
+
* The claims narrowed to the active role (`resolveActiveRole`), so the token carries that role alone: in `role` when it
|
|
12
|
+
* is held globally, in `app_roles` when held as a member, in both when both. Unchanged for someone without a role.
|
|
13
|
+
*/
|
|
14
|
+
const narrowClaims = (claims, wanted, organizations) => {
|
|
15
|
+
const global = organizations ? globalRoles(claims.role) : parseRoles(claims.role);
|
|
16
|
+
const membership = claims.app_roles ?? [];
|
|
17
|
+
const active = resolveActiveRole([.../* @__PURE__ */ new Set([...global, ...membership])], wanted);
|
|
18
|
+
if (active === void 0) return claims;
|
|
19
|
+
return {
|
|
20
|
+
...claims,
|
|
21
|
+
role: global.includes(active) ? active : noGlobalRole,
|
|
22
|
+
...claims.app_roles && { app_roles: membership.includes(active) ? [active] : [] }
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { narrowClaims, resolveActiveRole };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import "../organizations/role-definitions.js";
|
|
2
|
+
import { sessionRoles } from "./session-role.js";
|
|
3
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
4
|
+
import * as z$1 from "zod";
|
|
5
|
+
//#region src/better-auth/roles/roles-plugin.ts
|
|
6
|
+
/**
|
|
7
|
+
* Switching the role a session acts with, as switching its organization: the session keeps it, and its tokens carry it
|
|
8
|
+
* alone (see `narrowClaims`), so the API, the views and the menu follow it. The user keeps the last one chosen, for
|
|
9
|
+
* their next session. Better Auth's admin endpoints refuse a superuser acting with another role.
|
|
10
|
+
*/
|
|
11
|
+
const rolesPlugin = ({ definitions }) => ({
|
|
12
|
+
id: "protobase-roles",
|
|
13
|
+
schema: {
|
|
14
|
+
session: { fields: { activeRole: {
|
|
15
|
+
type: "string",
|
|
16
|
+
required: false,
|
|
17
|
+
input: false,
|
|
18
|
+
fieldName: "active_role"
|
|
19
|
+
} } },
|
|
20
|
+
user: { fields: { lastRole: {
|
|
21
|
+
type: "string",
|
|
22
|
+
required: false,
|
|
23
|
+
input: false,
|
|
24
|
+
fieldName: "last_role"
|
|
25
|
+
} } }
|
|
26
|
+
},
|
|
27
|
+
endpoints: {
|
|
28
|
+
/** `GET /role/current`: the role the session acts with, and the roles held there with their labels to switch to. */
|
|
29
|
+
currentRole: createAuthEndpoint("/role/current", {
|
|
30
|
+
method: "GET",
|
|
31
|
+
use: [sessionMiddleware]
|
|
32
|
+
}, async (ctx) => {
|
|
33
|
+
const { held, active } = await sessionRoles(ctx.context, ctx.context.session);
|
|
34
|
+
return ctx.json({
|
|
35
|
+
active: active ?? null,
|
|
36
|
+
roles: held.map((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
label: definitions.labels[name] ?? name
|
|
39
|
+
}))
|
|
40
|
+
});
|
|
41
|
+
}),
|
|
42
|
+
/** `POST /role/switch` with `{ role }`, one the person holds where the session works: its next token carries that role alone. */
|
|
43
|
+
switchRole: createAuthEndpoint("/role/switch", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
use: [sessionMiddleware],
|
|
46
|
+
body: z$1.object({ role: z$1.string().min(1) })
|
|
47
|
+
}, async (ctx) => {
|
|
48
|
+
const { user, session } = ctx.context.session;
|
|
49
|
+
const { held } = await sessionRoles(ctx.context, ctx.context.session);
|
|
50
|
+
if (!held.includes(ctx.body.role)) throw new APIError("FORBIDDEN", {
|
|
51
|
+
code: "ROLE_NOT_HELD",
|
|
52
|
+
message: "You do not hold this role."
|
|
53
|
+
});
|
|
54
|
+
await ctx.context.internalAdapter.updateSession(session.token, { activeRole: ctx.body.role });
|
|
55
|
+
await ctx.context.internalAdapter.updateUser(user.id, { lastRole: ctx.body.role });
|
|
56
|
+
return ctx.json({ role: ctx.body.role });
|
|
57
|
+
})
|
|
58
|
+
},
|
|
59
|
+
hooks: { before: [{
|
|
60
|
+
matcher: (ctx) => ctx.path?.startsWith("/admin/") ?? false,
|
|
61
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
62
|
+
const session = await getSessionFromCtx(ctx);
|
|
63
|
+
if (!session) return;
|
|
64
|
+
const { held, active } = await sessionRoles(ctx.context, session);
|
|
65
|
+
if (held.includes("admin") && active !== "admin") throw new APIError("FORBIDDEN", {
|
|
66
|
+
code: "ADMIN_ONLY",
|
|
67
|
+
message: "Switch to the admin role to manage users."
|
|
68
|
+
});
|
|
69
|
+
})
|
|
70
|
+
}] }
|
|
71
|
+
});
|
|
72
|
+
//#endregion
|
|
73
|
+
export { rolesPlugin };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseRoles } from "../parse-roles.js";
|
|
2
|
+
import { effectiveRoles, globalRoles } from "../organizations/app-roles.js";
|
|
3
|
+
import { findMember } from "../organizations/store.js";
|
|
4
|
+
import { resolveActiveRole } from "./active-role.js";
|
|
5
|
+
//#region src/better-auth/roles/session-role.ts
|
|
6
|
+
/**
|
|
7
|
+
* The roles someone holds where the session works: their roles, or with organizations their global roles and then
|
|
8
|
+
* their app roles as a member of the active organization.
|
|
9
|
+
*/
|
|
10
|
+
const heldRoles = async (context, user, session) => {
|
|
11
|
+
if (!context.hasPlugin("organization")) return parseRoles(user.role);
|
|
12
|
+
const organizationId = session.activeOrganizationId;
|
|
13
|
+
const member = organizationId ? await findMember(context.adapter, {
|
|
14
|
+
organizationId,
|
|
15
|
+
userId: user.id
|
|
16
|
+
}) : void 0;
|
|
17
|
+
return effectiveRoles(globalRoles(user.role), member?.appRoles);
|
|
18
|
+
};
|
|
19
|
+
/** The roles held and the one the session acts with, as its next token carries it. */
|
|
20
|
+
const sessionRoles = async (context, { user, session }) => {
|
|
21
|
+
const held = await heldRoles(context, user, session);
|
|
22
|
+
return {
|
|
23
|
+
held,
|
|
24
|
+
active: resolveActiveRole(held, [session.activeRole, user.lastRole])
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { heldRoles, sessionRoles };
|
|
@@ -36,8 +36,15 @@ const withError = (url, code) => {
|
|
|
36
36
|
* them). The session is short and never extended, and the person's two-factor and passkey requirements do not stop it.
|
|
37
37
|
*/
|
|
38
38
|
const staffSignInEndpoints = ({ operator, mailer }) => {
|
|
39
|
-
|
|
40
|
-
const clientOf = (context) =>
|
|
39
|
+
const clients = /* @__PURE__ */ new Map();
|
|
40
|
+
const clientOf = (context) => {
|
|
41
|
+
const callback = `${context.baseURL}/staff/callback`;
|
|
42
|
+
const found = clients.get(callback);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
const client = operatorClient(operator, callback);
|
|
45
|
+
clients.set(callback, client);
|
|
46
|
+
return client;
|
|
47
|
+
};
|
|
41
48
|
const startSession = async (ctx, started, claims) => {
|
|
42
49
|
const refusal = staffClaimsRefusal(claims, {
|
|
43
50
|
group: operator.group,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { membershipRoles } from "./organizations/role-definitions.js";
|
|
2
2
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
3
3
|
import { readSignInPolicy } from "./policy-store.js";
|
|
4
|
+
import { signInPasskeyDomains } from "./passkey-domain.js";
|
|
4
5
|
import { hasUsers } from "./users.js";
|
|
5
6
|
import { Hono } from "hono";
|
|
6
7
|
//#region src/better-auth/status-route.ts
|
|
@@ -10,7 +11,8 @@ import { Hono } from "hono";
|
|
|
10
11
|
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
11
12
|
* then; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
12
13
|
* one; `platformSignIn`, the id and name of the platform's provider when it is one of them; `staffSignIn`, the
|
|
13
|
-
* operator provider's name, when staff can sign in as people;
|
|
14
|
+
* operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
|
|
15
|
+
* one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; and with organizations, who may create them and the roles
|
|
14
16
|
* with their labels, `membership` for those a member can hold, and the roles each one's holders may also give.
|
|
15
17
|
*/
|
|
16
18
|
const statusRoute = (auth) => {
|
|
@@ -27,6 +29,7 @@ const statusRoute = (auth) => {
|
|
|
27
29
|
const platform = auth.platformSignIn;
|
|
28
30
|
const platformSignIn = platform && policy.platformSignIn === "allowed" && context.socialProviders.some((provider) => provider.id === platform.provider) ? platform : void 0;
|
|
29
31
|
const socialProviders = [...auth.socialProviders, ...platformSignIn ? [platformSignIn.provider] : []];
|
|
32
|
+
const passkeyDomains = signInMethods.includes("passkey") ? await signInPasskeyDomains(context.adapter, auth.passkeyDomain, auth.origins) : [];
|
|
30
33
|
return c.json({
|
|
31
34
|
needsAdmin: !await hasUsers(auth),
|
|
32
35
|
signInMethods,
|
|
@@ -34,6 +37,7 @@ const statusRoute = (auth) => {
|
|
|
34
37
|
socialProviders,
|
|
35
38
|
...platformSignIn && { platformSignIn },
|
|
36
39
|
...staffSignIn && { staffSignIn },
|
|
40
|
+
...passkeyDomains.length > 1 && { passkeyDomains },
|
|
37
41
|
...auth.organizations && { organizations: {
|
|
38
42
|
create: auth.organizations.create,
|
|
39
43
|
roles: auth.roleDefinitions.names.map((name) => ({
|
|
@@ -8,7 +8,8 @@ export type IssueTokenOptions = {
|
|
|
8
8
|
export declare const maxTokenTtlSeconds: number;
|
|
9
9
|
/**
|
|
10
10
|
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
11
|
-
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session.
|
|
11
|
+
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
|
|
12
|
+
* the role the user last chose, else their first.
|
|
12
13
|
* Host side only: there is no HTTP route for this.
|
|
13
14
|
*/
|
|
14
15
|
export declare const issueToken: (auth: AdminAuth, options: IssueTokenOptions) => Promise<{
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
import { narrowClaims } from "./roles/active-role.js";
|
|
2
3
|
//#region src/better-auth/tokens.ts
|
|
3
4
|
const defaultTtlSeconds = 900;
|
|
4
5
|
const maxTokenTtlSeconds = 86400;
|
|
5
6
|
/**
|
|
6
7
|
* Mints a bearer token for an existing user: signed with the JWT plugin's keys, same issuer, audience and claims
|
|
7
|
-
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session.
|
|
8
|
+
* (`sub`, `email`, `role`) as `/api/auth/token`, so the API accepts it like a token from a browser session. It carries
|
|
9
|
+
* the role the user last chose, else their first.
|
|
8
10
|
* Host side only: there is no HTTP route for this.
|
|
9
11
|
*/
|
|
10
12
|
const issueToken = async (auth, options) => {
|
|
@@ -21,12 +23,13 @@ const issueToken = async (auth, options) => {
|
|
|
21
23
|
if (!user) throw new Error(`No user with email ${options.email}`);
|
|
22
24
|
if (user.banned) throw new Error(`${options.email} is banned`);
|
|
23
25
|
const issuedAt = Math.floor(Date.now() / 1e3);
|
|
26
|
+
const { role } = narrowClaims({ role: parseRoles(user.role).join(",") }, [user.lastRole], Boolean(auth.organizations));
|
|
24
27
|
const { token } = await auth.api.signJWT({ body: { payload: {
|
|
25
28
|
iat: issuedAt,
|
|
26
29
|
exp: issuedAt + ttl,
|
|
27
30
|
sub: user.id,
|
|
28
31
|
email: user.email,
|
|
29
|
-
role
|
|
32
|
+
role
|
|
30
33
|
} } });
|
|
31
34
|
return {
|
|
32
35
|
token,
|
package/dist/changes/actor.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { runningJobs } from "../jobs/current.js";
|
|
2
|
+
import { dispatchJobName } from "./capture-sql.js";
|
|
1
3
|
//#region src/changes/actor.ts
|
|
2
4
|
/** The setting a write transaction sets so the change capture records who wrote, when a resource has events. */
|
|
3
5
|
const actorSettings = (deps, session) => {
|
|
@@ -8,5 +10,9 @@ const actorSettings = (deps, session) => {
|
|
|
8
10
|
};
|
|
9
11
|
return { [deps.actorSetting]: JSON.stringify(actor) };
|
|
10
12
|
};
|
|
13
|
+
/** After a write committed: has this process's dispatcher deliver its events now, rather than at its next poll. */
|
|
14
|
+
const wakeDispatch = (deps) => {
|
|
15
|
+
if (deps.actorSetting) runningJobs()?.wake(dispatchJobName);
|
|
16
|
+
};
|
|
11
17
|
//#endregion
|
|
12
|
-
export { actorSettings };
|
|
18
|
+
export { actorSettings, wakeDispatch };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { changesSchema } from "./events-options.js";
|
|
2
1
|
import { quoteIdent, tableSql } from "./capture-sql.js";
|
|
2
|
+
import { changesSchema } from "./events-options.js";
|
|
3
3
|
import { capturePlan, planSql, uncaptured } from "./capture-plan.js";
|
|
4
4
|
import { sql } from "kysely";
|
|
5
5
|
//#region src/changes/capture-schema.ts
|