@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.
@@ -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: email and password sign-in, no public sign-up (every account is created on the
17
- * host with `createUser`, or by an admin through the admin plugin), roles from the admin plugin's `role` column, 15 minute JWTs
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
- /** The options `createAuth` gives Better Auth, with the roles, default role and mailer already resolved. */
28
- const betterAuthOptions = (options, { roles, defaultRole, mailer }) => {
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: [admin({
49
- defaultRole: defaultRole ?? "unassigned",
50
- roles: Object.fromEntries(roles.map((role) => [role, role === "admin" ? adminAc : userAc]))
51
- }), jwt({ jwt: {
52
- expirationTime: tokenLifetime,
53
- issuer: options.baseURL,
54
- audience: options.baseURL,
55
- definePayload: ({ user }) => ({
56
- email: user.email,
57
- role: user.role
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: { "/sign-in/email": {
74
- window: 60,
75
- max: options.signInPerMinute ?? 5
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 };