@protobase/server 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistant/assistant-settings.js +2 -1
- package/dist/better-auth/admin-only.js +13 -0
- package/dist/better-auth/create-auth.d.ts +2357 -30
- package/dist/better-auth/create-auth.js +70 -24
- package/dist/better-auth/operator-provider.d.ts +29 -0
- package/dist/better-auth/operator-provider.js +130 -0
- package/dist/better-auth/parse-roles.d.ts +7 -0
- package/dist/better-auth/parse-roles.js +12 -0
- package/dist/better-auth/policy-plugin.js +142 -0
- package/dist/better-auth/policy-store.js +65 -0
- package/dist/better-auth/sign-in-enforcement.js +65 -0
- package/dist/better-auth/sign-in-mail.js +78 -0
- package/dist/better-auth/sign-in-policy.d.ts +49 -0
- package/dist/better-auth/sign-in-policy.js +83 -0
- package/dist/better-auth/staff-checks.js +65 -0
- package/dist/better-auth/staff-log.d.ts +14 -0
- package/dist/better-auth/staff-log.js +96 -0
- package/dist/better-auth/staff-plugin.js +61 -0
- package/dist/better-auth/staff-sign-in-endpoints.js +162 -0
- package/dist/better-auth/status-route.js +23 -8
- package/dist/better-auth/tokens.js +1 -1
- package/dist/better-auth/two-factor-challenge.js +23 -0
- package/dist/better-auth/users.d.ts +5 -7
- package/dist/better-auth/users.js +3 -10
- package/dist/create-admin.js +4 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -2
- package/dist/meta.js +3 -1
- package/dist/runtime/runtime-settings.d.ts +14 -0
- package/dist/runtime/runtime-settings.js +14 -0
- package/dist/types.d.ts +3 -0
- package/package.json +12 -9
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { readMailSettings } from "../mail/mail-settings.js";
|
|
2
2
|
import { smtpMailer } from "../mail/smtp-mailer.js";
|
|
3
|
+
import { readOperatorSettings, resolveOperator } from "./operator-provider.js";
|
|
3
4
|
import { passwordResetOptions } from "./password-reset.js";
|
|
5
|
+
import { signInPolicyPlugin } from "./policy-plugin.js";
|
|
6
|
+
import { emailCodeOptions, twoFactorCodeOptions, unusedEmailCodePaths } from "./sign-in-mail.js";
|
|
7
|
+
import { staffSignInPlugin } from "./staff-plugin.js";
|
|
8
|
+
import { twoFactorAfterPasswordOrCode } from "./two-factor-challenge.js";
|
|
9
|
+
import { passkey } from "@better-auth/passkey";
|
|
4
10
|
import { APIError } from "better-auth/api";
|
|
5
11
|
import { betterAuth } from "better-auth";
|
|
6
|
-
import { admin, jwt } from "better-auth/plugins";
|
|
12
|
+
import { admin, emailOTP, jwt } from "better-auth/plugins";
|
|
7
13
|
import { adminAc, userAc } from "better-auth/plugins/admin/access";
|
|
8
14
|
//#region src/better-auth/create-auth.ts
|
|
15
|
+
const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
|
|
9
16
|
const platformMailer = () => {
|
|
10
17
|
const settings = readMailSettings(globalThis.process?.env ?? {});
|
|
11
18
|
return settings && smtpMailer(settings);
|
|
@@ -13,8 +20,9 @@ const platformMailer = () => {
|
|
|
13
20
|
const tokenLifetime = "15m";
|
|
14
21
|
const authBasePath = "/api/auth";
|
|
15
22
|
/**
|
|
16
|
-
* Better Auth for a single-tenant admin:
|
|
17
|
-
*
|
|
23
|
+
* Better Auth for a single-tenant admin: sign-in with a password, an emailed code or a passkey, two-factor
|
|
24
|
+
* authentication, and the sign-in policy admins set; no public sign-up (every account is created on the host with
|
|
25
|
+
* `createUser`, or by an admin through the admin plugin), roles from the admin plugin's `role` column, 15 minute JWTs
|
|
18
26
|
* at `/api/auth/token` with the keys at `/api/auth/jwks`.
|
|
19
27
|
*/
|
|
20
28
|
const roleName = /^[a-z][a-z0-9_-]*$/;
|
|
@@ -24,10 +32,12 @@ const normalizeRoles = (roles = ["admin", "user"]) => {
|
|
|
24
32
|
if (bad !== void 0) throw new Error(`Invalid role "${bad}": use lowercase letters, digits, "_" or "-"`);
|
|
25
33
|
return ["admin", ...new Set(roles.filter((role) => role !== "admin"))];
|
|
26
34
|
};
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
const adminImpersonationPaths = ["/admin/impersonate-user", "/admin/stop-impersonating"];
|
|
36
|
+
/** The options `createAuth` gives Better Auth, with the roles, default role, mailer and operator provider already resolved. */
|
|
37
|
+
const betterAuthOptions = (options, { roles, defaultRole, mailer, operator }) => {
|
|
29
38
|
const secure = options.baseURL.startsWith("https://");
|
|
30
39
|
return {
|
|
40
|
+
appName: options.appName ?? "Protobase",
|
|
31
41
|
database: options.database,
|
|
32
42
|
baseURL: options.baseURL,
|
|
33
43
|
basePath: authBasePath,
|
|
@@ -45,24 +55,49 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer }) => {
|
|
|
45
55
|
},
|
|
46
56
|
...options.socialProviders && { socialProviders: options.socialProviders },
|
|
47
57
|
...options.encryptOAuthTokens && { account: { encryptOAuthTokens: true } },
|
|
48
|
-
plugins: [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
plugins: [
|
|
59
|
+
admin({
|
|
60
|
+
defaultRole: defaultRole ?? "unassigned",
|
|
61
|
+
roles: Object.fromEntries(roles.map((role) => [role, role === "admin" ? adminAc : userAc]))
|
|
62
|
+
}),
|
|
63
|
+
jwt({
|
|
64
|
+
jwt: {
|
|
65
|
+
expirationTime: tokenLifetime,
|
|
66
|
+
issuer: options.baseURL,
|
|
67
|
+
audience: options.baseURL,
|
|
68
|
+
definePayload: ({ user }) => ({
|
|
69
|
+
email: user.email,
|
|
70
|
+
role: user.role
|
|
71
|
+
})
|
|
72
|
+
},
|
|
73
|
+
disableSettingJwtHeader: true
|
|
74
|
+
}),
|
|
75
|
+
twoFactorAfterPasswordOrCode({
|
|
76
|
+
allowPasswordless: true,
|
|
77
|
+
...mailer && { otpOptions: twoFactorCodeOptions(mailer) }
|
|
78
|
+
}),
|
|
79
|
+
passkey(),
|
|
80
|
+
...mailer ? [emailOTP(emailCodeOptions(mailer))] : [],
|
|
81
|
+
signInPolicyPlugin({
|
|
82
|
+
mail: Boolean(mailer),
|
|
83
|
+
socialProviders: Object.keys(options.socialProviders ?? {}),
|
|
84
|
+
...operator && { operator: operator.name }
|
|
85
|
+
}),
|
|
86
|
+
staffSignInPlugin({
|
|
87
|
+
...operator && { operator },
|
|
88
|
+
...mailer && { mailer }
|
|
58
89
|
})
|
|
59
|
-
|
|
90
|
+
],
|
|
91
|
+
disabledPaths: [...unusedEmailCodePaths, ...adminImpersonationPaths],
|
|
60
92
|
databaseHooks: { user: { create: {
|
|
61
|
-
before: async (user) => {
|
|
93
|
+
before: async (user, context) => {
|
|
62
94
|
const given = String(user.role ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
63
95
|
const unknown = given.find((name) => !roles.includes(name));
|
|
64
96
|
if (given.length === 0 || unknown !== void 0) throw new APIError("BAD_REQUEST", { message: `Choose a role for the new user: ${roles.join(", ")}` });
|
|
65
|
-
return { data: user
|
|
97
|
+
return { data: context?.path === "/admin/create-user" ? {
|
|
98
|
+
...user,
|
|
99
|
+
emailVerified: true
|
|
100
|
+
} : user };
|
|
66
101
|
},
|
|
67
102
|
...options.onUserCreated && { after: options.onUserCreated }
|
|
68
103
|
} } },
|
|
@@ -70,10 +105,16 @@ const betterAuthOptions = (options, { roles, defaultRole, mailer }) => {
|
|
|
70
105
|
enabled: true,
|
|
71
106
|
window: 60,
|
|
72
107
|
max: 100,
|
|
73
|
-
customRules: {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
customRules: {
|
|
109
|
+
"/sign-in/email": {
|
|
110
|
+
window: 60,
|
|
111
|
+
max: options.signInPerMinute ?? 5
|
|
112
|
+
},
|
|
113
|
+
"/staff/sign-in": {
|
|
114
|
+
window: 60,
|
|
115
|
+
max: 5
|
|
116
|
+
}
|
|
117
|
+
}
|
|
77
118
|
},
|
|
78
119
|
advanced: {
|
|
79
120
|
useSecureCookies: secure,
|
|
@@ -91,17 +132,22 @@ const createAuth = (options) => {
|
|
|
91
132
|
const others = roles.filter((role) => role !== "admin");
|
|
92
133
|
const defaultRole = options.defaultRole ?? (others.length === 1 ? others[0] : void 0);
|
|
93
134
|
const mailer = options.mailer === void 0 ? platformMailer() : options.mailer || void 0;
|
|
135
|
+
const provider = options.operator === void 0 ? platformOperator() : options.operator || void 0;
|
|
136
|
+
const operator = provider && resolveOperator(provider);
|
|
94
137
|
const auth = betterAuth(betterAuthOptions(options, {
|
|
95
138
|
roles,
|
|
96
139
|
defaultRole,
|
|
97
|
-
mailer
|
|
140
|
+
mailer,
|
|
141
|
+
operator
|
|
98
142
|
}));
|
|
99
143
|
const socialProviders = Object.keys(options.socialProviders ?? {});
|
|
100
144
|
return Object.assign(auth, {
|
|
101
145
|
roles,
|
|
102
146
|
defaultRole,
|
|
147
|
+
mail: Boolean(mailer),
|
|
103
148
|
passwordReset: Boolean(mailer),
|
|
104
|
-
socialProviders
|
|
149
|
+
socialProviders,
|
|
150
|
+
operator: operator?.name
|
|
105
151
|
});
|
|
106
152
|
};
|
|
107
153
|
//#endregion
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/better-auth/operator-provider.d.ts
|
|
2
|
+
/**
|
|
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 `{baseURL}/api/auth/staff/callback`.
|
|
5
|
+
*/
|
|
6
|
+
export type OperatorProvider = {
|
|
7
|
+
/** The provider's issuer URL; its settings are read from `{issuer}/.well-known/openid-configuration`. */
|
|
8
|
+
issuer: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientSecret: string;
|
|
11
|
+
/** Shown on the staff sign-in page as "Continue with {name}". Default `the operator`. */
|
|
12
|
+
name?: string;
|
|
13
|
+
/** The group in the ID token's `groups` claim that lets staff sign in as people. Default `protobase-staff-access`. */
|
|
14
|
+
group?: string;
|
|
15
|
+
/** Scopes asked for besides `openid email profile`, for a provider that puts `groups` in the ID token only for one of its own. */
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
/** `acr` values that count as a strong sign-in, asked for as `acr_values`, for a provider that reports levels instead of `amr`. */
|
|
18
|
+
acrValues?: string[];
|
|
19
|
+
/** How long a staff session lasts, in minutes: default 30, at most 240. It is not extended while in use. */
|
|
20
|
+
sessionMinutes?: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The operator provider the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
|
|
24
|
+
* `PROTOBASE_OPERATOR_CLIENT_SECRET` (with `PROTOBASE_OPERATOR_NAME` and `PROTOBASE_OPERATOR_GROUP` optional);
|
|
25
|
+
* `undefined` when none of the three is set. Some but not all of them, or an issuer that is not a URL, stops the
|
|
26
|
+
* server at startup.
|
|
27
|
+
*/
|
|
28
|
+
export declare const readOperatorSettings: (env: Record<string, string | undefined>) => OperatorProvider | undefined;
|
|
29
|
+
//#endregion
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
2
|
+
//#region src/better-auth/operator-provider.ts
|
|
3
|
+
const operatorIssuerVariable = "PROTOBASE_OPERATOR_ISSUER";
|
|
4
|
+
const operatorClientIdVariable = "PROTOBASE_OPERATOR_CLIENT_ID";
|
|
5
|
+
const operatorClientSecretVariable = "PROTOBASE_OPERATOR_CLIENT_SECRET";
|
|
6
|
+
const operatorNameVariable = "PROTOBASE_OPERATOR_NAME";
|
|
7
|
+
const operatorGroupVariable = "PROTOBASE_OPERATOR_GROUP";
|
|
8
|
+
/**
|
|
9
|
+
* The operator provider the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
|
|
10
|
+
* `PROTOBASE_OPERATOR_CLIENT_SECRET` (with `PROTOBASE_OPERATOR_NAME` and `PROTOBASE_OPERATOR_GROUP` optional);
|
|
11
|
+
* `undefined` when none of the three is set. Some but not all of them, or an issuer that is not a URL, stops the
|
|
12
|
+
* server at startup.
|
|
13
|
+
*/
|
|
14
|
+
const readOperatorSettings = (env) => {
|
|
15
|
+
const required = [
|
|
16
|
+
operatorIssuerVariable,
|
|
17
|
+
operatorClientIdVariable,
|
|
18
|
+
operatorClientSecretVariable
|
|
19
|
+
];
|
|
20
|
+
const [issuer, clientId, clientSecret] = required.map((name) => env[name]?.trim() || void 0);
|
|
21
|
+
if (!issuer && !clientId && !clientSecret) return void 0;
|
|
22
|
+
if (!issuer || !clientId || !clientSecret) throw new Error(`${required.join(", ")} go together: set all three for staff sign-in, or none`);
|
|
23
|
+
const name = env[operatorNameVariable]?.trim();
|
|
24
|
+
const group = env[operatorGroupVariable]?.trim();
|
|
25
|
+
return {
|
|
26
|
+
issuer,
|
|
27
|
+
clientId,
|
|
28
|
+
clientSecret,
|
|
29
|
+
...name && { name },
|
|
30
|
+
...group && { group }
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const defaultStaffGroup = "protobase-staff-access";
|
|
34
|
+
const defaultSessionMinutes = 30;
|
|
35
|
+
const maxSessionMinutes = 240;
|
|
36
|
+
/** The provider with its defaults filled in; a session length out of range or an issuer that is not a URL is an error. */
|
|
37
|
+
const resolveOperator = (provider) => {
|
|
38
|
+
if (!URL.canParse(provider.issuer)) throw new Error(`The operator provider's issuer must be a URL, not "${provider.issuer}"`);
|
|
39
|
+
const sessionMinutes = provider.sessionMinutes ?? defaultSessionMinutes;
|
|
40
|
+
if (!Number.isInteger(sessionMinutes) || sessionMinutes < 1 || sessionMinutes > maxSessionMinutes) throw new Error(`The operator provider's sessionMinutes must be a whole number from 1 to ${maxSessionMinutes}`);
|
|
41
|
+
return {
|
|
42
|
+
...provider,
|
|
43
|
+
name: provider.name ?? "the operator",
|
|
44
|
+
group: provider.group ?? "protobase-staff-access",
|
|
45
|
+
scopes: provider.scopes ?? [],
|
|
46
|
+
acrValues: provider.acrValues ?? [],
|
|
47
|
+
sessionMinutes
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
|
+
/** A random value for `state`, `nonce` and the PKCE verifier. */
|
|
52
|
+
const randomValue = () => base64url(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)));
|
|
53
|
+
const challengeOf = async (verifier) => base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
|
|
54
|
+
/**
|
|
55
|
+
* An OpenID Connect client of the operator provider: the authorization code flow with PKCE, a client secret, and an
|
|
56
|
+
* ID token verified against the provider's keys, issuer, this client and the nonce. The provider's settings and keys
|
|
57
|
+
* are read on first use and kept; settings that fail to load are read again next time.
|
|
58
|
+
*/
|
|
59
|
+
const operatorClient = (operator, redirectURI) => {
|
|
60
|
+
let discovery;
|
|
61
|
+
let keys;
|
|
62
|
+
const settings = () => {
|
|
63
|
+
discovery ??= (async () => {
|
|
64
|
+
const response = await fetch(`${operator.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`);
|
|
65
|
+
if (!response.ok) throw new Error(`The operator provider's settings answered ${response.status}`);
|
|
66
|
+
const found = await response.json();
|
|
67
|
+
if (found.issuer !== operator.issuer) throw new Error(`The operator provider names its issuer "${found.issuer}", not "${operator.issuer}"`);
|
|
68
|
+
return found;
|
|
69
|
+
})().catch((error) => {
|
|
70
|
+
discovery = void 0;
|
|
71
|
+
throw error;
|
|
72
|
+
});
|
|
73
|
+
return discovery;
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
/** Where to send the staff member's browser; their sign-in there must be at most 15 minutes old. */
|
|
77
|
+
authorizationURL: async ({ state, nonce, verifier }) => {
|
|
78
|
+
const url = new URL((await settings()).authorization_endpoint);
|
|
79
|
+
url.search = new URLSearchParams({
|
|
80
|
+
response_type: "code",
|
|
81
|
+
client_id: operator.clientId,
|
|
82
|
+
redirect_uri: redirectURI,
|
|
83
|
+
scope: [
|
|
84
|
+
"openid",
|
|
85
|
+
"email",
|
|
86
|
+
"profile",
|
|
87
|
+
...operator.scopes
|
|
88
|
+
].join(" "),
|
|
89
|
+
state,
|
|
90
|
+
nonce,
|
|
91
|
+
code_challenge: await challengeOf(verifier),
|
|
92
|
+
code_challenge_method: "S256",
|
|
93
|
+
max_age: String(900),
|
|
94
|
+
...operator.acrValues.length > 0 && { acr_values: operator.acrValues.join(" ") }
|
|
95
|
+
}).toString();
|
|
96
|
+
return url.toString();
|
|
97
|
+
},
|
|
98
|
+
/** Trades the code for an ID token and returns its verified claims; rejects when anything does not match. */
|
|
99
|
+
signedInStaff: async ({ code, nonce, verifier }) => {
|
|
100
|
+
const found = await settings();
|
|
101
|
+
const response = await fetch(found.token_endpoint, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
105
|
+
accept: "application/json",
|
|
106
|
+
authorization: `Basic ${btoa(`${encodeURIComponent(operator.clientId)}:${encodeURIComponent(operator.clientSecret)}`)}`
|
|
107
|
+
},
|
|
108
|
+
body: new URLSearchParams({
|
|
109
|
+
grant_type: "authorization_code",
|
|
110
|
+
code,
|
|
111
|
+
redirect_uri: redirectURI,
|
|
112
|
+
code_verifier: verifier
|
|
113
|
+
})
|
|
114
|
+
});
|
|
115
|
+
if (!response.ok) throw new Error(`The operator provider refused the code: ${response.status}`);
|
|
116
|
+
const { id_token: idToken } = await response.json();
|
|
117
|
+
if (typeof idToken !== "string") throw new Error("The operator provider sent no ID token");
|
|
118
|
+
keys ??= createRemoteJWKSet(new URL(found.jwks_uri));
|
|
119
|
+
const { payload } = await jwtVerify(idToken, keys, {
|
|
120
|
+
issuer: operator.issuer,
|
|
121
|
+
audience: operator.clientId
|
|
122
|
+
});
|
|
123
|
+
if (payload.nonce !== nonce) throw new Error("The ID token is for another sign-in");
|
|
124
|
+
if (typeof payload.sub !== "string") throw new Error("The ID token has no subject");
|
|
125
|
+
return payload;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
export { defaultStaffGroup, operatorClient, operatorClientIdVariable, operatorClientSecretVariable, operatorGroupVariable, operatorIssuerVariable, operatorNameVariable, randomValue, readOperatorSettings, resolveOperator };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/better-auth/parse-roles.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The admin plugin stores roles as a comma separated string. A value written as a JS array through a Postgres driver
|
|
4
|
+
* comes back as an array literal (`{admin,sales}`); that is read too, and rewritten as a plain string on the next change.
|
|
5
|
+
*/
|
|
6
|
+
export declare const parseRoles: (role: string | string[] | null | undefined) => string[];
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/better-auth/parse-roles.ts
|
|
2
|
+
/**
|
|
3
|
+
* The admin plugin stores roles as a comma separated string. A value written as a JS array through a Postgres driver
|
|
4
|
+
* comes back as an array literal (`{admin,sales}`); that is read too, and rewritten as a plain string on the next change.
|
|
5
|
+
*/
|
|
6
|
+
const parseRoles = (role) => {
|
|
7
|
+
if (Array.isArray(role)) return role;
|
|
8
|
+
const text = (role ?? "user").trim();
|
|
9
|
+
return (text.startsWith("{") && text.endsWith("}") ? text.slice(1, -1) : text).split(",").map((name) => name.trim().replace(/^"|"$/g, "")).filter(Boolean);
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { parseRoles };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isStaffSession } from "./staff-checks.js";
|
|
2
|
+
import { effectiveSignInPolicy, missingRequiredMethods, parseSignInPolicy, signInPolicyProblem } from "./sign-in-policy.js";
|
|
3
|
+
import { readSignInPolicy, saveSignInPolicy, signInPolicySchema } from "./policy-store.js";
|
|
4
|
+
import { isPoliced, lastPasskeyRequired, policyRefusal, setupRequired } from "./sign-in-enforcement.js";
|
|
5
|
+
import { adminOnly } from "./admin-only.js";
|
|
6
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
7
|
+
import * as z$1 from "zod";
|
|
8
|
+
//#region src/better-auth/policy-plugin.ts
|
|
9
|
+
const accountMethods = async (context, user, socialProviders) => {
|
|
10
|
+
const accounts = await context.internalAdapter.findAccounts(user.id);
|
|
11
|
+
const passkeys = await context.adapter.count({
|
|
12
|
+
model: "passkey",
|
|
13
|
+
where: [{
|
|
14
|
+
field: "userId",
|
|
15
|
+
value: user.id
|
|
16
|
+
}]
|
|
17
|
+
});
|
|
18
|
+
const app = await context.adapter.findOne({
|
|
19
|
+
model: "twoFactor",
|
|
20
|
+
where: [{
|
|
21
|
+
field: "userId",
|
|
22
|
+
value: user.id
|
|
23
|
+
}]
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
password: accounts.some((account) => account.providerId === "credential"),
|
|
27
|
+
passkeys,
|
|
28
|
+
twoFactor: Boolean(user.twoFactorEnabled),
|
|
29
|
+
authenticatorApp: Boolean(user.twoFactorEnabled) && app !== null && app.verified !== false,
|
|
30
|
+
socialSignIn: accounts.some((account) => socialProviders.includes(account.providerId))
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const refuse = (status, refusal) => new APIError(status, refusal);
|
|
34
|
+
/**
|
|
35
|
+
* Protobase's sign-in policy as a Better Auth plugin: its table, the endpoints that read and change it, and a hook that
|
|
36
|
+
* applies it to Better Auth's own endpoints. A method the policy turns off answers `403 SIGN_IN_METHOD_FORBIDDEN`;
|
|
37
|
+
* someone without what it requires gets a session, so they can set it up, but no API token (`403 SIGN_IN_SETUP_REQUIRED`).
|
|
38
|
+
*/
|
|
39
|
+
const signInPolicyPlugin = ({ mail, socialProviders, operator }) => {
|
|
40
|
+
const currentPolicy = async (context) => effectiveSignInPolicy((await readSignInPolicy(context.adapter)).policy, { mail });
|
|
41
|
+
const policyAdmin = adminOnly("Only an admin can change how people sign in.");
|
|
42
|
+
const policyAnswer = async (context) => {
|
|
43
|
+
const stored = await readSignInPolicy(context.adapter);
|
|
44
|
+
const savedBy = stored.savedBy && (await context.internalAdapter.findUserById(stored.savedBy))?.email;
|
|
45
|
+
return {
|
|
46
|
+
policy: stored.policy,
|
|
47
|
+
effective: effectiveSignInPolicy(stored.policy, { mail }),
|
|
48
|
+
mail,
|
|
49
|
+
...operator && { operator },
|
|
50
|
+
...stored.savedAt && { savedAt: stored.savedAt },
|
|
51
|
+
...savedBy && { savedBy }
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
id: "protobase-sign-in-policy",
|
|
56
|
+
schema: signInPolicySchema,
|
|
57
|
+
endpoints: {
|
|
58
|
+
/** `GET /policy/sign-in`, for admins: the saved policy, how it applies (`effective`), and whether mail is on. */
|
|
59
|
+
getSignInPolicy: createAuthEndpoint("/policy/sign-in", {
|
|
60
|
+
method: "GET",
|
|
61
|
+
use: [policyAdmin]
|
|
62
|
+
}, async (ctx) => ctx.json(await policyAnswer(ctx.context))),
|
|
63
|
+
/** `POST /policy/sign-in`, for admins: saves a policy, or answers 400 with why it cannot be saved. */
|
|
64
|
+
setSignInPolicy: createAuthEndpoint("/policy/sign-in", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
use: [policyAdmin],
|
|
67
|
+
body: z$1.record(z$1.string(), z$1.unknown())
|
|
68
|
+
}, async (ctx) => {
|
|
69
|
+
const policy = parseSignInPolicy(ctx.body);
|
|
70
|
+
if (!policy) throw refuse("BAD_REQUEST", {
|
|
71
|
+
code: "INVALID_SIGN_IN_POLICY",
|
|
72
|
+
message: "Choose one of the offered rules for every sign-in method."
|
|
73
|
+
});
|
|
74
|
+
const user = ctx.context.session.user;
|
|
75
|
+
const problem = signInPolicyProblem(policy, {
|
|
76
|
+
mail,
|
|
77
|
+
admin: await accountMethods(ctx.context, user, socialProviders)
|
|
78
|
+
});
|
|
79
|
+
if (problem) throw refuse("BAD_REQUEST", {
|
|
80
|
+
code: "SIGN_IN_POLICY_REFUSED",
|
|
81
|
+
message: problem
|
|
82
|
+
});
|
|
83
|
+
await saveSignInPolicy(ctx.context.adapter, policy, user.id);
|
|
84
|
+
return ctx.json(await policyAnswer(ctx.context));
|
|
85
|
+
}),
|
|
86
|
+
/**
|
|
87
|
+
* `GET /account/sign-in-methods`, for the signed-in user: the policy as it applies, what the account has, and what
|
|
88
|
+
* it still has to set up; nothing for a staff session, which cannot set anything up for the person.
|
|
89
|
+
*/
|
|
90
|
+
getSignInMethods: createAuthEndpoint("/account/sign-in-methods", {
|
|
91
|
+
method: "GET",
|
|
92
|
+
use: [sessionMiddleware]
|
|
93
|
+
}, async (ctx) => {
|
|
94
|
+
const policy = await currentPolicy(ctx.context);
|
|
95
|
+
const { socialSignIn: _, ...account } = await accountMethods(ctx.context, ctx.context.session.user, socialProviders);
|
|
96
|
+
return ctx.json({
|
|
97
|
+
policy,
|
|
98
|
+
mail,
|
|
99
|
+
account,
|
|
100
|
+
missing: isStaffSession(ctx.context.session.session) ? [] : missingRequiredMethods(policy, account)
|
|
101
|
+
});
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
hooks: { before: [{
|
|
105
|
+
matcher: (context) => isPoliced(context.path),
|
|
106
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
107
|
+
const path = ctx.path;
|
|
108
|
+
if (path === "/email-otp/send-verification-otp" && ctx.body?.type !== "sign-in") throw refuse("BAD_REQUEST", {
|
|
109
|
+
code: "SIGN_IN_CODES_ONLY",
|
|
110
|
+
message: "Only sign-in codes can be emailed."
|
|
111
|
+
});
|
|
112
|
+
const policy = await currentPolicy(ctx.context);
|
|
113
|
+
const refusal = policyRefusal(policy, {
|
|
114
|
+
path,
|
|
115
|
+
body: ctx.body
|
|
116
|
+
});
|
|
117
|
+
if (refusal) throw refuse("FORBIDDEN", refusal);
|
|
118
|
+
if (path === "/sign-in/email-otp") return vouchForCreatedAccount(ctx.context, ctx.body.email);
|
|
119
|
+
if (!(path === "/token" && (policy.twoFactor === "required" || policy.passkey === "required") || path === "/passkey/delete-passkey" && policy.passkey === "required")) return;
|
|
120
|
+
const session = await getSessionFromCtx(ctx);
|
|
121
|
+
if (!session || isStaffSession(session.session)) return;
|
|
122
|
+
const account = await accountMethods(ctx.context, session.user, socialProviders);
|
|
123
|
+
if (path === "/token" && missingRequiredMethods(policy, account).length > 0) throw refuse("FORBIDDEN", setupRequired);
|
|
124
|
+
if (path === "/passkey/delete-passkey" && account.passkeys <= 1) throw refuse("FORBIDDEN", lastPasskeyRequired);
|
|
125
|
+
})
|
|
126
|
+
}] }
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Accounts are created by an admin or on the host, who vouch for the address, so they count as verified. Better Auth
|
|
131
|
+
* would otherwise treat a first emailed-code sign-in to an unverified account as taking it over, and delete its
|
|
132
|
+
* password. New accounts are created verified (see `createAuth`); this marks one created before that, which has a
|
|
133
|
+
* password, when an emailed code is used for it. A sign-up through a provider has no password and is left alone.
|
|
134
|
+
*/
|
|
135
|
+
const vouchForCreatedAccount = async (context, email) => {
|
|
136
|
+
if (typeof email !== "string") return;
|
|
137
|
+
const found = await context.internalAdapter.findUserByEmail(email.toLowerCase(), { includeAccounts: true });
|
|
138
|
+
if (!found || found.user.emailVerified || !found.accounts.some((account) => account.providerId === "credential")) return;
|
|
139
|
+
await context.internalAdapter.updateUser(found.user.id, { emailVerified: true });
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
export { signInPolicyPlugin };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defaultSignInPolicy, parseSignInPolicy } from "./sign-in-policy.js";
|
|
2
|
+
//#region src/better-auth/policy-store.ts
|
|
3
|
+
/** Better Auth's model for the sign-in policy: one row per save, the newest applies, the older ones are its history. */
|
|
4
|
+
const signInPolicyModel = "signInPolicy";
|
|
5
|
+
const signInPolicySchema = { [signInPolicyModel]: { fields: {
|
|
6
|
+
password: {
|
|
7
|
+
type: "string",
|
|
8
|
+
required: true
|
|
9
|
+
},
|
|
10
|
+
emailCode: {
|
|
11
|
+
type: "string",
|
|
12
|
+
required: true
|
|
13
|
+
},
|
|
14
|
+
passkey: {
|
|
15
|
+
type: "string",
|
|
16
|
+
required: true
|
|
17
|
+
},
|
|
18
|
+
twoFactor: {
|
|
19
|
+
type: "string",
|
|
20
|
+
required: true
|
|
21
|
+
},
|
|
22
|
+
staffAccess: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
defaultValue: "allowed"
|
|
26
|
+
},
|
|
27
|
+
createdAt: {
|
|
28
|
+
type: "date",
|
|
29
|
+
required: true
|
|
30
|
+
},
|
|
31
|
+
createdBy: {
|
|
32
|
+
type: "string",
|
|
33
|
+
required: false
|
|
34
|
+
}
|
|
35
|
+
} } };
|
|
36
|
+
/** The newest saved policy, or the default when none was saved. A row that no longer parses counts as none. */
|
|
37
|
+
const readSignInPolicy = async (adapter) => {
|
|
38
|
+
const [row] = await adapter.findMany({
|
|
39
|
+
model: signInPolicyModel,
|
|
40
|
+
sortBy: {
|
|
41
|
+
field: "createdAt",
|
|
42
|
+
direction: "desc"
|
|
43
|
+
},
|
|
44
|
+
limit: 1
|
|
45
|
+
});
|
|
46
|
+
const policy = row && parseSignInPolicy(row);
|
|
47
|
+
if (!policy) return { policy: defaultSignInPolicy };
|
|
48
|
+
return {
|
|
49
|
+
policy,
|
|
50
|
+
savedAt: new Date(row.createdAt).toISOString(),
|
|
51
|
+
...row.createdBy && { savedBy: row.createdBy }
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
const saveSignInPolicy = async (adapter, policy, savedBy) => {
|
|
55
|
+
await adapter.create({
|
|
56
|
+
model: signInPolicyModel,
|
|
57
|
+
data: {
|
|
58
|
+
...policy,
|
|
59
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
60
|
+
createdBy: savedBy
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { readSignInPolicy, saveSignInPolicy, signInPolicyModel, signInPolicySchema };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { signInPolicyMethods } from "./sign-in-policy.js";
|
|
2
|
+
//#region src/better-auth/sign-in-enforcement.ts
|
|
3
|
+
const passwordPaths = [
|
|
4
|
+
"/sign-in/email",
|
|
5
|
+
"/request-password-reset",
|
|
6
|
+
"/reset-password"
|
|
7
|
+
];
|
|
8
|
+
const emailCodePaths = ["/sign-in/email-otp"];
|
|
9
|
+
const passkeyPaths = [
|
|
10
|
+
"/passkey/generate-authenticate-options",
|
|
11
|
+
"/passkey/verify-authentication",
|
|
12
|
+
"/passkey/generate-register-options",
|
|
13
|
+
"/passkey/verify-registration"
|
|
14
|
+
];
|
|
15
|
+
const staffSignInPath = "/staff/sign-in";
|
|
16
|
+
const accountPaths = [
|
|
17
|
+
"/email-otp/send-verification-otp",
|
|
18
|
+
"/passkey/delete-passkey",
|
|
19
|
+
"/two-factor/enable",
|
|
20
|
+
"/two-factor/disable",
|
|
21
|
+
"/token"
|
|
22
|
+
];
|
|
23
|
+
/** Whether the policy can refuse a request to `path`; every other request goes through without reading the policy. */
|
|
24
|
+
const isPoliced = (path) => path !== void 0 && ([
|
|
25
|
+
...passwordPaths,
|
|
26
|
+
...emailCodePaths,
|
|
27
|
+
...passkeyPaths,
|
|
28
|
+
...accountPaths,
|
|
29
|
+
"/staff/sign-in"
|
|
30
|
+
].includes(path) || path.startsWith("/reset-password/"));
|
|
31
|
+
const methodOf = (path, body) => {
|
|
32
|
+
if (passwordPaths.includes(path) || path.startsWith("/reset-password/")) return "password";
|
|
33
|
+
if (emailCodePaths.includes(path)) return "emailCode";
|
|
34
|
+
if (path === "/email-otp/send-verification-otp" && body?.type === "sign-in") return "emailCode";
|
|
35
|
+
if (passkeyPaths.includes(path)) return "passkey";
|
|
36
|
+
if (path === "/two-factor/enable") return "twoFactor";
|
|
37
|
+
if (path === "/staff/sign-in") return "staffAccess";
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Why the policy refuses a request to Better Auth at `path`, or `undefined` when it lets it through: a method the
|
|
41
|
+
* policy turns off, or turning off two-factor authentication while the policy requires it. Removing the last passkey
|
|
42
|
+
* while passkeys are required depends on the account, so the plugin checks that itself.
|
|
43
|
+
*/
|
|
44
|
+
const policyRefusal = (policy, { path, body }) => {
|
|
45
|
+
const method = methodOf(path, body);
|
|
46
|
+
if (method && policy[method] === "forbidden") return {
|
|
47
|
+
code: "SIGN_IN_METHOD_FORBIDDEN",
|
|
48
|
+
message: signInPolicyMethods[method].forbidden
|
|
49
|
+
};
|
|
50
|
+
if (path === "/two-factor/disable" && policy.twoFactor === "required") return {
|
|
51
|
+
code: "TWO_FACTOR_REQUIRED",
|
|
52
|
+
message: "Two-factor authentication is required for every account, so it cannot be turned off."
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/** The answer of `/token` to someone who has not set up what the policy requires. */
|
|
56
|
+
const setupRequired = {
|
|
57
|
+
code: "SIGN_IN_SETUP_REQUIRED",
|
|
58
|
+
message: "Set up what this app requires for signing in first."
|
|
59
|
+
};
|
|
60
|
+
const lastPasskeyRequired = {
|
|
61
|
+
code: "PASSKEY_REQUIRED",
|
|
62
|
+
message: "Passkeys are required for every account, so the last one cannot be removed."
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { isPoliced, lastPasskeyRequired, policyRefusal, setupRequired, staffSignInPath };
|