@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const codeLifetimeMinutes = 5;
|
|
2
|
+
const codeLifetime = "5 minutes";
|
|
3
|
+
/** The email with a code to sign in with instead of a password. */
|
|
4
|
+
const signInCodeEmail = ({ to, code }) => ({
|
|
5
|
+
to,
|
|
6
|
+
subject: "Your sign-in code",
|
|
7
|
+
text: [
|
|
8
|
+
"Use this code to sign in:",
|
|
9
|
+
"",
|
|
10
|
+
code,
|
|
11
|
+
"",
|
|
12
|
+
`It works for ${codeLifetime}, once.`,
|
|
13
|
+
"If you did not try to sign in, ignore this email: nobody can sign in without the code.",
|
|
14
|
+
""
|
|
15
|
+
].join("\n")
|
|
16
|
+
});
|
|
17
|
+
/** The email with the second step of a sign-in, for an account with two-factor authentication by email. */
|
|
18
|
+
const twoFactorCodeEmail = ({ to, code }) => ({
|
|
19
|
+
to,
|
|
20
|
+
subject: "Your verification code",
|
|
21
|
+
text: [
|
|
22
|
+
"Use this code to finish signing in:",
|
|
23
|
+
"",
|
|
24
|
+
code,
|
|
25
|
+
"",
|
|
26
|
+
`It works for ${codeLifetime}, once.`,
|
|
27
|
+
"If you did not just sign in, someone else may know your password or have your sign-in code: change your password.",
|
|
28
|
+
""
|
|
29
|
+
].join("\n")
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Better Auth's email OTP plugin as sign-in only: never a sign-up, codes stored hashed, sent with `mailer`. The
|
|
33
|
+
* plugin's other uses (email verification, its own password reset, changing the address) are not offered.
|
|
34
|
+
*/
|
|
35
|
+
const emailCodeOptions = (mailer) => ({
|
|
36
|
+
disableSignUp: true,
|
|
37
|
+
expiresIn: 300,
|
|
38
|
+
storeOTP: "hashed",
|
|
39
|
+
sendVerificationOTP: ({ email, otp }) => mailer.send(signInCodeEmail({
|
|
40
|
+
to: email,
|
|
41
|
+
code: otp
|
|
42
|
+
}))
|
|
43
|
+
});
|
|
44
|
+
/** The email OTP plugin's endpoints Protobase does not use; `createAuth` turns them off. */
|
|
45
|
+
const unusedEmailCodePaths = [
|
|
46
|
+
"/email-otp/check-verification-otp",
|
|
47
|
+
"/email-otp/verify-email",
|
|
48
|
+
"/email-otp/request-password-reset",
|
|
49
|
+
"/forget-password/email-otp",
|
|
50
|
+
"/email-otp/reset-password",
|
|
51
|
+
"/email-otp/request-email-change",
|
|
52
|
+
"/email-otp/change-email"
|
|
53
|
+
];
|
|
54
|
+
/** The two-factor plugin's emailed code, sent with `mailer`; stored hashed. */
|
|
55
|
+
const twoFactorCodeOptions = (mailer) => ({
|
|
56
|
+
period: codeLifetimeMinutes,
|
|
57
|
+
storeOTP: "hashed",
|
|
58
|
+
sendOTP: ({ user, otp }) => mailer.send(twoFactorCodeEmail({
|
|
59
|
+
to: user.email,
|
|
60
|
+
code: otp
|
|
61
|
+
}))
|
|
62
|
+
});
|
|
63
|
+
/** The email to someone whose app lets staff sign in as people only when they are told: who, why, and until when. */
|
|
64
|
+
const staffSignInEmail = ({ to, staff, reason, expiresAt }) => ({
|
|
65
|
+
to,
|
|
66
|
+
subject: "Staff signed in as you",
|
|
67
|
+
text: [
|
|
68
|
+
`${staff}, of the team that runs this app, signed in as you to help. The reason they gave:`,
|
|
69
|
+
"",
|
|
70
|
+
reason,
|
|
71
|
+
"",
|
|
72
|
+
`Their session ends by ${expiresAt.toUTCString()}. The app's admins see it in the app's log of staff sign-ins.`,
|
|
73
|
+
"If you did not expect this, tell your admin.",
|
|
74
|
+
""
|
|
75
|
+
].join("\n")
|
|
76
|
+
});
|
|
77
|
+
//#endregion
|
|
78
|
+
export { emailCodeOptions, signInCodeEmail, staffSignInEmail, twoFactorCodeEmail, twoFactorCodeOptions, unusedEmailCodePaths };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/better-auth/sign-in-policy.d.ts
|
|
2
|
+
/** What a policy says about one way of signing in: people may use it, must set it up, or cannot use it. */
|
|
3
|
+
export type SignInRule = 'allowed' | 'required' | 'forbidden';
|
|
4
|
+
/** Whether staff of the operator may sign in as people: yes, yes and each person gets an email about it, or no. */
|
|
5
|
+
export type StaffAccessRule = 'allowed' | 'notify' | 'forbidden';
|
|
6
|
+
/**
|
|
7
|
+
* The app's sign-in policy. `password`, `emailCode` and `passkey` are ways to sign in; `twoFactor` is the second step
|
|
8
|
+
* after a password or an emailed code. Only a passkey and two-factor authentication can be required: they are set up
|
|
9
|
+
* once per person, so "required" sends everyone without one to set it up. `staffAccess` is whether the operator's
|
|
10
|
+
* staff may sign in as people, for support.
|
|
11
|
+
*/
|
|
12
|
+
export type SignInPolicy = {
|
|
13
|
+
password: 'allowed' | 'forbidden';
|
|
14
|
+
emailCode: 'allowed' | 'forbidden';
|
|
15
|
+
passkey: SignInRule;
|
|
16
|
+
twoFactor: SignInRule;
|
|
17
|
+
staffAccess: StaffAccessRule;
|
|
18
|
+
};
|
|
19
|
+
/** Each method with the rules it takes, its name, and what the server answers when the policy turns it off. */
|
|
20
|
+
export declare const signInPolicyMethods: {
|
|
21
|
+
readonly password: {
|
|
22
|
+
readonly label: "Password";
|
|
23
|
+
readonly rules: readonly ["allowed", "forbidden"];
|
|
24
|
+
readonly forbidden: "Signing in with a password is turned off.";
|
|
25
|
+
};
|
|
26
|
+
readonly emailCode: {
|
|
27
|
+
readonly label: "Emailed sign-in code";
|
|
28
|
+
readonly rules: readonly ["allowed", "forbidden"];
|
|
29
|
+
readonly forbidden: "Signing in with an emailed code is turned off.";
|
|
30
|
+
};
|
|
31
|
+
readonly passkey: {
|
|
32
|
+
readonly label: "Passkeys";
|
|
33
|
+
readonly rules: readonly ["allowed", "required", "forbidden"];
|
|
34
|
+
readonly forbidden: "Passkeys are turned off.";
|
|
35
|
+
};
|
|
36
|
+
readonly twoFactor: {
|
|
37
|
+
readonly label: "Two-factor authentication";
|
|
38
|
+
readonly rules: readonly ["allowed", "required", "forbidden"];
|
|
39
|
+
readonly forbidden: "Two-factor authentication is turned off.";
|
|
40
|
+
};
|
|
41
|
+
readonly staffAccess: {
|
|
42
|
+
readonly label: "Staff sign-in as a person";
|
|
43
|
+
readonly rules: readonly ["allowed", "notify", "forbidden"];
|
|
44
|
+
readonly forbidden: "Staff sign-in is turned off for this app.";
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** Everything allowed and nothing required: the policy of an app whose admins never saved one. */
|
|
48
|
+
export declare const defaultSignInPolicy: SignInPolicy;
|
|
49
|
+
//#endregion
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
//#region src/better-auth/sign-in-policy.ts
|
|
2
|
+
/** Each method with the rules it takes, its name, and what the server answers when the policy turns it off. */
|
|
3
|
+
const signInPolicyMethods = {
|
|
4
|
+
password: {
|
|
5
|
+
label: "Password",
|
|
6
|
+
rules: ["allowed", "forbidden"],
|
|
7
|
+
forbidden: "Signing in with a password is turned off."
|
|
8
|
+
},
|
|
9
|
+
emailCode: {
|
|
10
|
+
label: "Emailed sign-in code",
|
|
11
|
+
rules: ["allowed", "forbidden"],
|
|
12
|
+
forbidden: "Signing in with an emailed code is turned off."
|
|
13
|
+
},
|
|
14
|
+
passkey: {
|
|
15
|
+
label: "Passkeys",
|
|
16
|
+
rules: [
|
|
17
|
+
"allowed",
|
|
18
|
+
"required",
|
|
19
|
+
"forbidden"
|
|
20
|
+
],
|
|
21
|
+
forbidden: "Passkeys are turned off."
|
|
22
|
+
},
|
|
23
|
+
twoFactor: {
|
|
24
|
+
label: "Two-factor authentication",
|
|
25
|
+
rules: [
|
|
26
|
+
"allowed",
|
|
27
|
+
"required",
|
|
28
|
+
"forbidden"
|
|
29
|
+
],
|
|
30
|
+
forbidden: "Two-factor authentication is turned off."
|
|
31
|
+
},
|
|
32
|
+
staffAccess: {
|
|
33
|
+
label: "Staff sign-in as a person",
|
|
34
|
+
rules: [
|
|
35
|
+
"allowed",
|
|
36
|
+
"notify",
|
|
37
|
+
"forbidden"
|
|
38
|
+
],
|
|
39
|
+
forbidden: "Staff sign-in is turned off for this app."
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Everything allowed and nothing required: the policy of an app whose admins never saved one. */
|
|
43
|
+
const defaultSignInPolicy = {
|
|
44
|
+
password: "allowed",
|
|
45
|
+
emailCode: "allowed",
|
|
46
|
+
passkey: "allowed",
|
|
47
|
+
twoFactor: "allowed",
|
|
48
|
+
staffAccess: "allowed"
|
|
49
|
+
};
|
|
50
|
+
const methodNames = Object.keys(signInPolicyMethods);
|
|
51
|
+
/** A policy from a request body or a stored row; `undefined` when a method is missing or has a rule it does not take. */
|
|
52
|
+
const parseSignInPolicy = (input) => {
|
|
53
|
+
if (typeof input !== "object" || input === null) return void 0;
|
|
54
|
+
const values = input;
|
|
55
|
+
return methodNames.every((method) => signInPolicyMethods[method].rules.includes(values[method])) ? Object.fromEntries(methodNames.map((method) => [method, values[method]])) : void 0;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* The policy as it applies. Emailed codes need mail; without mail, password sign-in is on whatever the policy says, so
|
|
59
|
+
* an app whose mail settings went missing does not lock out everyone who had only emailed codes left. Staff sign-in
|
|
60
|
+
* that emails the person is off without mail, since nobody would be told.
|
|
61
|
+
*/
|
|
62
|
+
const effectiveSignInPolicy = (policy, { mail }) => mail ? policy : {
|
|
63
|
+
...policy,
|
|
64
|
+
password: "allowed",
|
|
65
|
+
emailCode: "forbidden",
|
|
66
|
+
...policy.staffAccess === "notify" && { staffAccess: "forbidden" }
|
|
67
|
+
};
|
|
68
|
+
/** The required methods `account` has not set up yet, in the order a setup page asks for them. */
|
|
69
|
+
const missingRequiredMethods = (policy, account) => [...policy.twoFactor === "required" && !account.twoFactor ? ["twoFactor"] : [], ...policy.passkey === "required" && account.passkeys === 0 ? ["passkey"] : []];
|
|
70
|
+
/**
|
|
71
|
+
* Why `policy` cannot be saved by an admin whose account is `admin`, or `undefined` when it can. It must keep a way
|
|
72
|
+
* in for someone who has set nothing up (a password, or an emailed code with mail on), and leave the admin a way to
|
|
73
|
+
* sign in. What it requires can always be set up after signing in.
|
|
74
|
+
*/
|
|
75
|
+
const signInPolicyProblem = (policy, { mail, admin }) => {
|
|
76
|
+
if (policy.password === "forbidden" && policy.emailCode === "forbidden") return "Keep password or emailed-code sign-in on: people without a passkey need one of them to sign in.";
|
|
77
|
+
if (policy.password === "forbidden" && !mail) return "Emailed codes need mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM), so password sign-in has to stay on.";
|
|
78
|
+
if (policy.staffAccess === "notify" && !mail) return "Emailing people when staff sign in as them needs mail settings (PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM).";
|
|
79
|
+
if (policy.password === "forbidden" && policy.twoFactor === "required") return "Two-factor authentication is turned on with the account password, so it cannot be required while password sign-in is off.";
|
|
80
|
+
return policy.password === "allowed" && admin.password || policy.emailCode === "allowed" && mail || policy.passkey !== "forbidden" && admin.passkeys > 0 || admin.socialSignIn ? void 0 : "This policy would leave you without a way to sign in. Add a passkey first, or keep password sign-in on.";
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
export { defaultSignInPolicy, effectiveSignInPolicy, missingRequiredMethods, parseSignInPolicy, signInPolicyMethods, signInPolicyProblem };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const strongMethods = [
|
|
2
|
+
"mfa",
|
|
3
|
+
"hwk",
|
|
4
|
+
"swk"
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* Whether the operator provider says the staff member signed in strongly: with a passkey or a second step (`amr`), or
|
|
8
|
+
* at one of the configured `acr` levels.
|
|
9
|
+
*/
|
|
10
|
+
const isStrongSignIn = (claims, acrValues) => {
|
|
11
|
+
return (Array.isArray(claims.amr) ? claims.amr : []).some((method) => strongMethods.includes(method)) || typeof claims.acr === "string" && acrValues.includes(claims.acr);
|
|
12
|
+
};
|
|
13
|
+
const groupsOf = (groups) => Array.isArray(groups) ? groups : typeof groups === "string" ? groups.split(/[\s,]+/) : [];
|
|
14
|
+
const permissionMissing = {
|
|
15
|
+
code: "STAFF_PERMISSION_MISSING",
|
|
16
|
+
message: "Your staff account has no permission to sign in as people here."
|
|
17
|
+
};
|
|
18
|
+
const signInNotStrong = {
|
|
19
|
+
code: "STAFF_SIGN_IN_NOT_STRONG",
|
|
20
|
+
message: "Sign in to the staff account with a passkey or a second step first."
|
|
21
|
+
};
|
|
22
|
+
const signInStale = {
|
|
23
|
+
code: "STAFF_SIGN_IN_STALE",
|
|
24
|
+
message: "Your staff sign-in is too old: sign in to the staff account again."
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Why a verified staff ID token cannot start a session as someone, or `undefined` when it can: the staff member needs
|
|
28
|
+
* the explicit permission (the configured group), a strong sign-in, and one made in the last 15 minutes.
|
|
29
|
+
*/
|
|
30
|
+
const staffClaimsRefusal = (claims, { group, acrValues, now }) => {
|
|
31
|
+
if (!groupsOf(claims.groups).includes(group)) return permissionMissing;
|
|
32
|
+
if (!isStrongSignIn(claims, acrValues)) return signInNotStrong;
|
|
33
|
+
if (typeof claims.auth_time !== "number" || now - claims.auth_time > 900) return signInStale;
|
|
34
|
+
};
|
|
35
|
+
/** Why `reason` cannot be logged as the reason for signing in as someone, or `undefined` when it can. */
|
|
36
|
+
const reasonProblem = (reason) => reason.trim().length < 10 ? `Say why you sign in as this person, in at least 10 characters, for the app's log.` : void 0;
|
|
37
|
+
/** The staff member as the log and the banner name them: the address, else the provider's subject. */
|
|
38
|
+
const staffLabel = (claims) => typeof claims.email === "string" && claims.email ? claims.email : claims.sub;
|
|
39
|
+
const accountChanges = [
|
|
40
|
+
"/change-password",
|
|
41
|
+
"/set-password",
|
|
42
|
+
"/change-email",
|
|
43
|
+
"/delete-user",
|
|
44
|
+
"/link-social",
|
|
45
|
+
"/unlink-account",
|
|
46
|
+
"/revoke-sessions",
|
|
47
|
+
"/revoke-other-sessions",
|
|
48
|
+
"/two-factor/enable",
|
|
49
|
+
"/two-factor/disable",
|
|
50
|
+
"/two-factor/generate-backup-codes",
|
|
51
|
+
"/passkey/generate-register-options",
|
|
52
|
+
"/passkey/verify-registration",
|
|
53
|
+
"/passkey/update-passkey",
|
|
54
|
+
"/passkey/delete-passkey"
|
|
55
|
+
];
|
|
56
|
+
const staffCannotChange = {
|
|
57
|
+
code: "STAFF_CANNOT_CHANGE_SIGN_IN",
|
|
58
|
+
message: "Staff cannot change a person's sign-in or the sign-in policy."
|
|
59
|
+
};
|
|
60
|
+
/** Whether a staff session's request to `path` is refused: changes to the person's sign-in, and saving the sign-in policy. */
|
|
61
|
+
const isRefusedForStaff = (path, method) => accountChanges.includes(path) || path === "/policy/sign-in" && method === "POST";
|
|
62
|
+
/** Whether a Better Auth session is one staff started as someone: the admin plugin's `impersonatedBy` names the staff member. */
|
|
63
|
+
const isStaffSession = (session) => Boolean(session.impersonatedBy);
|
|
64
|
+
//#endregion
|
|
65
|
+
export { isRefusedForStaff, isStaffSession, isStrongSignIn, permissionMissing, reasonProblem, signInNotStrong, signInStale, staffCannotChange, staffClaimsRefusal, staffLabel };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "better-auth";
|
|
2
|
+
//#region src/better-auth/staff-log.d.ts
|
|
3
|
+
/** A staff sign-in as the log and the banner show it: who, as whom, why, and when it started and ends (or ended). */
|
|
4
|
+
export type StaffSignIn = {
|
|
5
|
+
id: string;
|
|
6
|
+
user: string;
|
|
7
|
+
staff: string;
|
|
8
|
+
staffName?: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
endedAt?: string;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region src/better-auth/staff-log.ts
|
|
2
|
+
/** Better Auth's model for the log of staff sign-ins: one row per session staff started as someone. */
|
|
3
|
+
const staffSignInModel = "staffSignIn";
|
|
4
|
+
const staffSignInSchema = { [staffSignInModel]: { fields: {
|
|
5
|
+
userId: {
|
|
6
|
+
type: "string",
|
|
7
|
+
required: true
|
|
8
|
+
},
|
|
9
|
+
userEmail: {
|
|
10
|
+
type: "string",
|
|
11
|
+
required: true
|
|
12
|
+
},
|
|
13
|
+
staff: {
|
|
14
|
+
type: "string",
|
|
15
|
+
required: true
|
|
16
|
+
},
|
|
17
|
+
staffName: {
|
|
18
|
+
type: "string",
|
|
19
|
+
required: false
|
|
20
|
+
},
|
|
21
|
+
staffSubject: {
|
|
22
|
+
type: "string",
|
|
23
|
+
required: true
|
|
24
|
+
},
|
|
25
|
+
issuer: {
|
|
26
|
+
type: "string",
|
|
27
|
+
required: true
|
|
28
|
+
},
|
|
29
|
+
reason: {
|
|
30
|
+
type: "string",
|
|
31
|
+
required: true
|
|
32
|
+
},
|
|
33
|
+
sessionId: {
|
|
34
|
+
type: "string",
|
|
35
|
+
required: true
|
|
36
|
+
},
|
|
37
|
+
startedAt: {
|
|
38
|
+
type: "date",
|
|
39
|
+
required: true
|
|
40
|
+
},
|
|
41
|
+
expiresAt: {
|
|
42
|
+
type: "date",
|
|
43
|
+
required: true
|
|
44
|
+
},
|
|
45
|
+
endedAt: {
|
|
46
|
+
type: "date",
|
|
47
|
+
required: false
|
|
48
|
+
}
|
|
49
|
+
} } };
|
|
50
|
+
const entry = (row) => ({
|
|
51
|
+
id: row.id,
|
|
52
|
+
user: row.userEmail,
|
|
53
|
+
staff: row.staff,
|
|
54
|
+
...row.staffName && { staffName: row.staffName },
|
|
55
|
+
reason: row.reason,
|
|
56
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
57
|
+
expiresAt: new Date(row.expiresAt).toISOString(),
|
|
58
|
+
...row.endedAt && { endedAt: new Date(row.endedAt).toISOString() }
|
|
59
|
+
});
|
|
60
|
+
const logStaffSignIn = async (adapter, data) => {
|
|
61
|
+
await adapter.create({
|
|
62
|
+
model: staffSignInModel,
|
|
63
|
+
data
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const staffSignInOfSession = async (adapter, sessionId) => {
|
|
67
|
+
const row = await adapter.findOne({
|
|
68
|
+
model: staffSignInModel,
|
|
69
|
+
where: [{
|
|
70
|
+
field: "sessionId",
|
|
71
|
+
value: sessionId
|
|
72
|
+
}]
|
|
73
|
+
});
|
|
74
|
+
return row ? entry(row) : void 0;
|
|
75
|
+
};
|
|
76
|
+
const endStaffSignIn = async (adapter, sessionId) => {
|
|
77
|
+
await adapter.update({
|
|
78
|
+
model: staffSignInModel,
|
|
79
|
+
where: [{
|
|
80
|
+
field: "sessionId",
|
|
81
|
+
value: sessionId
|
|
82
|
+
}],
|
|
83
|
+
update: { endedAt: /* @__PURE__ */ new Date() }
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
/** The newest staff sign-ins first, at most `limit`. */
|
|
87
|
+
const listStaffSignIns = async (adapter, limit = 100) => (await adapter.findMany({
|
|
88
|
+
model: staffSignInModel,
|
|
89
|
+
sortBy: {
|
|
90
|
+
field: "startedAt",
|
|
91
|
+
direction: "desc"
|
|
92
|
+
},
|
|
93
|
+
limit
|
|
94
|
+
})).map(entry);
|
|
95
|
+
//#endregion
|
|
96
|
+
export { endStaffSignIn, listStaffSignIns, logStaffSignIn, staffSignInModel, staffSignInOfSession, staffSignInSchema };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isRefusedForStaff, isStaffSession, staffCannotChange } from "./staff-checks.js";
|
|
2
|
+
import { adminOnly } from "./admin-only.js";
|
|
3
|
+
import { endStaffSignIn, listStaffSignIns, staffSignInOfSession, staffSignInSchema } from "./staff-log.js";
|
|
4
|
+
import { staffSignInEndpoints } from "./staff-sign-in-endpoints.js";
|
|
5
|
+
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
|
+
import { deleteSessionCookie } from "better-auth/cookies";
|
|
7
|
+
//#region src/better-auth/staff-plugin.ts
|
|
8
|
+
/**
|
|
9
|
+
* Staff of the operator sign in as a person of this app, for support (see `staffSignInEndpoints`). A staff session
|
|
10
|
+
* shows who started it and why, can be stopped, and cannot change the person's sign-in or the sign-in policy. Every
|
|
11
|
+
* staff sign-in is logged for the app's admins. The plugin is there without an operator provider too, so its table
|
|
12
|
+
* exists from the first `auth:migrate`.
|
|
13
|
+
*/
|
|
14
|
+
const staffSignInPlugin = ({ operator, mailer }) => ({
|
|
15
|
+
id: "protobase-staff-sign-in",
|
|
16
|
+
schema: staffSignInSchema,
|
|
17
|
+
endpoints: {
|
|
18
|
+
...operator && staffSignInEndpoints({
|
|
19
|
+
operator,
|
|
20
|
+
...mailer && { mailer }
|
|
21
|
+
}),
|
|
22
|
+
/** `GET /staff/session`: for a staff session, who started it, as whom, why, and until when; otherwise `{ staff: null }`. */
|
|
23
|
+
getStaffSession: createAuthEndpoint("/staff/session", {
|
|
24
|
+
method: "GET",
|
|
25
|
+
use: [sessionMiddleware]
|
|
26
|
+
}, async (ctx) => {
|
|
27
|
+
const { session } = ctx.context.session;
|
|
28
|
+
if (!isStaffSession(session)) return ctx.json({ staff: null });
|
|
29
|
+
return ctx.json({ staff: await staffSignInOfSession(ctx.context.adapter, session.id) ?? null });
|
|
30
|
+
}),
|
|
31
|
+
/** `POST /staff/stop`: ends the staff session, signs the browser out, and notes the end in the log. */
|
|
32
|
+
stopStaffSession: createAuthEndpoint("/staff/stop", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
use: [sessionMiddleware]
|
|
35
|
+
}, async (ctx) => {
|
|
36
|
+
const { session } = ctx.context.session;
|
|
37
|
+
if (!isStaffSession(session)) throw new APIError("BAD_REQUEST", {
|
|
38
|
+
code: "NOT_A_STAFF_SESSION",
|
|
39
|
+
message: "This is not a staff session."
|
|
40
|
+
});
|
|
41
|
+
await ctx.context.internalAdapter.deleteSession(session.token);
|
|
42
|
+
await endStaffSignIn(ctx.context.adapter, session.id);
|
|
43
|
+
deleteSessionCookie(ctx);
|
|
44
|
+
return ctx.json({ stopped: true });
|
|
45
|
+
}),
|
|
46
|
+
/** `GET /staff/sign-ins`, for admins: the log of staff sign-ins, newest first. */
|
|
47
|
+
listStaffSignIns: createAuthEndpoint("/staff/sign-ins", {
|
|
48
|
+
method: "GET",
|
|
49
|
+
use: [adminOnly("Only an admin can read the log of staff sign-ins.")]
|
|
50
|
+
}, async (ctx) => ctx.json({ signIns: await listStaffSignIns(ctx.context.adapter) }))
|
|
51
|
+
},
|
|
52
|
+
hooks: { before: [{
|
|
53
|
+
matcher: (context) => context.path !== void 0 && isRefusedForStaff(context.path, context.request?.method ?? "POST"),
|
|
54
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
55
|
+
const session = await getSessionFromCtx(ctx);
|
|
56
|
+
if (session && isStaffSession(session.session)) throw new APIError("FORBIDDEN", staffCannotChange);
|
|
57
|
+
})
|
|
58
|
+
}] }
|
|
59
|
+
});
|
|
60
|
+
//#endregion
|
|
61
|
+
export { staffSignInPlugin };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { reasonProblem, staffClaimsRefusal, staffLabel } from "./staff-checks.js";
|
|
2
|
+
import { operatorClient, randomValue } from "./operator-provider.js";
|
|
3
|
+
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
4
|
+
import { readSignInPolicy } from "./policy-store.js";
|
|
5
|
+
import { policyRefusal, staffSignInPath } from "./sign-in-enforcement.js";
|
|
6
|
+
import { staffSignInEmail } from "./sign-in-mail.js";
|
|
7
|
+
import { logStaffSignIn } from "./staff-log.js";
|
|
8
|
+
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
+
import * as z$1 from "zod";
|
|
10
|
+
import { expireCookie, setSessionCookie } from "better-auth/cookies";
|
|
11
|
+
//#region src/better-auth/staff-sign-in-endpoints.ts
|
|
12
|
+
const startLifetimeMs = 6e5;
|
|
13
|
+
const stateCookie = "staff_state";
|
|
14
|
+
const stateIdentifier = (state) => `staff-sign-in:${state}`;
|
|
15
|
+
const expired = {
|
|
16
|
+
code: "STAFF_SIGN_IN_EXPIRED",
|
|
17
|
+
message: "This staff sign-in expired or was used already: start again."
|
|
18
|
+
};
|
|
19
|
+
const providerFailed = {
|
|
20
|
+
code: "STAFF_SIGN_IN_FAILED",
|
|
21
|
+
message: "The staff sign-in did not go through at the operator provider."
|
|
22
|
+
};
|
|
23
|
+
const userUnavailable = {
|
|
24
|
+
code: "STAFF_USER_UNAVAILABLE",
|
|
25
|
+
message: "There is no account with this address, or it is disabled."
|
|
26
|
+
};
|
|
27
|
+
const withError = (url, code) => {
|
|
28
|
+
const target = new URL(url, "http://relative");
|
|
29
|
+
target.searchParams.set("staff-error", code);
|
|
30
|
+
return url.startsWith("/") ? `${target.pathname}${target.search}${target.hash}` : target.toString();
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The two steps of a staff sign-in: the staff sign-in page starts it with the person's address and a reason, the staff
|
|
34
|
+
* member signs in at the operator provider, and the provider sends them back to the callback, which checks their
|
|
35
|
+
* permission and sign-in and starts a session as the person, of Better Auth's admin plugin (`impersonatedBy` names
|
|
36
|
+
* them). The session is short and never extended, and the person's two-factor and passkey requirements do not stop it.
|
|
37
|
+
*/
|
|
38
|
+
const staffSignInEndpoints = ({ operator, mailer }) => {
|
|
39
|
+
let client;
|
|
40
|
+
const clientOf = (context) => client ??= operatorClient(operator, `${context.baseURL}/staff/callback`);
|
|
41
|
+
const startSession = async (ctx, started, claims) => {
|
|
42
|
+
const refusal = staffClaimsRefusal(claims, {
|
|
43
|
+
group: operator.group,
|
|
44
|
+
acrValues: operator.acrValues,
|
|
45
|
+
now: Math.floor(Date.now() / 1e3)
|
|
46
|
+
});
|
|
47
|
+
if (refusal) return refusal;
|
|
48
|
+
const policy = effectiveSignInPolicy((await readSignInPolicy(ctx.context.adapter)).policy, { mail: Boolean(mailer) });
|
|
49
|
+
const off = policyRefusal(policy, { path: staffSignInPath });
|
|
50
|
+
if (off) return off;
|
|
51
|
+
const found = await ctx.context.internalAdapter.findUserByEmail(started.email.toLowerCase());
|
|
52
|
+
if (!found || found.user.banned) return userUnavailable;
|
|
53
|
+
const staff = staffLabel(claims);
|
|
54
|
+
const expiresAt = new Date(Date.now() + operator.sessionMinutes * 60 * 1e3);
|
|
55
|
+
if (policy.staffAccess === "notify" && mailer) await mailer.send(staffSignInEmail({
|
|
56
|
+
to: found.user.email,
|
|
57
|
+
staff,
|
|
58
|
+
reason: started.reason,
|
|
59
|
+
expiresAt
|
|
60
|
+
}));
|
|
61
|
+
const session = await ctx.context.internalAdapter.createSession(found.user.id, true, {
|
|
62
|
+
impersonatedBy: staff,
|
|
63
|
+
expiresAt
|
|
64
|
+
}, true);
|
|
65
|
+
await logStaffSignIn(ctx.context.adapter, {
|
|
66
|
+
userId: found.user.id,
|
|
67
|
+
userEmail: found.user.email,
|
|
68
|
+
staff,
|
|
69
|
+
...typeof claims.name === "string" && { staffName: claims.name },
|
|
70
|
+
staffSubject: claims.sub,
|
|
71
|
+
issuer: operator.issuer,
|
|
72
|
+
reason: started.reason,
|
|
73
|
+
sessionId: session.id,
|
|
74
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
75
|
+
expiresAt
|
|
76
|
+
});
|
|
77
|
+
await setSessionCookie(ctx, {
|
|
78
|
+
session,
|
|
79
|
+
user: found.user
|
|
80
|
+
}, true);
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
/**
|
|
84
|
+
* `POST /staff/sign-in` with `{ email, reason, callbackURL }`: answers `{ url }` at the operator provider, where
|
|
85
|
+
* the staff member signs in. Only this browser can finish it, within ten minutes, once.
|
|
86
|
+
*/
|
|
87
|
+
startStaffSignIn: createAuthEndpoint(staffSignInPath, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
body: z$1.object({
|
|
90
|
+
email: z$1.email(),
|
|
91
|
+
reason: z$1.string(),
|
|
92
|
+
callbackURL: z$1.string()
|
|
93
|
+
})
|
|
94
|
+
}, async (ctx) => {
|
|
95
|
+
const problem = reasonProblem(ctx.body.reason);
|
|
96
|
+
if (problem) throw new APIError("BAD_REQUEST", {
|
|
97
|
+
code: "STAFF_REASON_REQUIRED",
|
|
98
|
+
message: problem
|
|
99
|
+
});
|
|
100
|
+
if (!ctx.context.isTrustedOrigin(ctx.body.callbackURL, { allowRelativePaths: true })) throw new APIError("FORBIDDEN", {
|
|
101
|
+
code: "INVALID_CALLBACK_URL",
|
|
102
|
+
message: "The page to come back to is not on a trusted origin."
|
|
103
|
+
});
|
|
104
|
+
const [state, nonce, verifier] = [
|
|
105
|
+
randomValue(),
|
|
106
|
+
randomValue(),
|
|
107
|
+
randomValue()
|
|
108
|
+
];
|
|
109
|
+
const started = {
|
|
110
|
+
email: ctx.body.email,
|
|
111
|
+
reason: ctx.body.reason.trim(),
|
|
112
|
+
callbackURL: ctx.body.callbackURL,
|
|
113
|
+
nonce,
|
|
114
|
+
verifier
|
|
115
|
+
};
|
|
116
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
117
|
+
identifier: stateIdentifier(state),
|
|
118
|
+
value: JSON.stringify(started),
|
|
119
|
+
expiresAt: new Date(Date.now() + startLifetimeMs)
|
|
120
|
+
});
|
|
121
|
+
const cookie = ctx.context.createAuthCookie(stateCookie, { maxAge: startLifetimeMs / 1e3 });
|
|
122
|
+
await ctx.setSignedCookie(cookie.name, state, ctx.context.secret, cookie.attributes);
|
|
123
|
+
return ctx.json({ url: await clientOf(ctx.context).authorizationURL({
|
|
124
|
+
state,
|
|
125
|
+
nonce,
|
|
126
|
+
verifier
|
|
127
|
+
}) });
|
|
128
|
+
}),
|
|
129
|
+
/**
|
|
130
|
+
* `GET /staff/callback`, where the operator provider sends the staff member back: signed in as the person, to the
|
|
131
|
+
* `callbackURL` they started from, or there with `?staff-error=<code>`.
|
|
132
|
+
*/
|
|
133
|
+
staffCallback: createAuthEndpoint("/staff/callback", {
|
|
134
|
+
method: "GET",
|
|
135
|
+
query: z$1.object({
|
|
136
|
+
state: z$1.string().optional(),
|
|
137
|
+
code: z$1.string().optional()
|
|
138
|
+
})
|
|
139
|
+
}, async (ctx) => {
|
|
140
|
+
const cookie = ctx.context.createAuthCookie(stateCookie);
|
|
141
|
+
const browserState = await ctx.getSignedCookie(cookie.name, ctx.context.secret);
|
|
142
|
+
expireCookie(ctx, cookie);
|
|
143
|
+
const { state, code } = ctx.query;
|
|
144
|
+
if (!state || state !== browserState) throw new APIError("BAD_REQUEST", expired);
|
|
145
|
+
const stored = await ctx.context.internalAdapter.consumeVerificationValue(stateIdentifier(state));
|
|
146
|
+
if (!stored) throw new APIError("BAD_REQUEST", expired);
|
|
147
|
+
const started = JSON.parse(stored.value);
|
|
148
|
+
if (!code) throw ctx.redirect(withError(started.callbackURL, providerFailed.code));
|
|
149
|
+
const claims = await clientOf(ctx.context).signedInStaff({
|
|
150
|
+
code,
|
|
151
|
+
nonce: started.nonce,
|
|
152
|
+
verifier: started.verifier
|
|
153
|
+
}).catch((error) => {
|
|
154
|
+
ctx.context.logger.error("Staff sign-in at the operator provider failed", error);
|
|
155
|
+
});
|
|
156
|
+
const refusal = claims ? await startSession(ctx, started, claims) : providerFailed;
|
|
157
|
+
throw ctx.redirect(refusal ? withError(started.callbackURL, refusal.code) : started.callbackURL);
|
|
158
|
+
})
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
//#endregion
|
|
162
|
+
export { staffSignInEndpoints };
|
|
@@ -1,18 +1,33 @@
|
|
|
1
|
+
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
2
|
+
import { readSignInPolicy } from "./policy-store.js";
|
|
1
3
|
import { hasUsers } from "./users.js";
|
|
2
4
|
import { Hono } from "hono";
|
|
3
5
|
//#region src/better-auth/status-route.ts
|
|
4
6
|
/**
|
|
5
|
-
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one
|
|
6
|
-
* `
|
|
7
|
-
* `
|
|
7
|
+
* `GET {authBase}/status`: `needsAdmin` while the admin store has no user, so a login page can say how to create one;
|
|
8
|
+
* `signInMethods`, the ways to sign in the sign-in policy leaves on (`password`, `emailCode`, `passkey`), so it offers
|
|
9
|
+
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
10
|
+
* then; the ids of the configured `socialProviders`, so it offers "Continue with GitHub" when `github` is one; and
|
|
11
|
+
* `staffSignIn`, the operator provider's name, when staff can sign in as people.
|
|
8
12
|
*/
|
|
9
13
|
const statusRoute = (auth) => {
|
|
10
14
|
const app = new Hono();
|
|
11
|
-
app.get("/status", async (c) =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
app.get("/status", async (c) => {
|
|
16
|
+
const policy = effectiveSignInPolicy((await readSignInPolicy((await auth.$context).adapter)).policy, { mail: auth.mail });
|
|
17
|
+
const signInMethods = [
|
|
18
|
+
...policy.password === "allowed" ? ["password"] : [],
|
|
19
|
+
...policy.emailCode === "allowed" ? ["emailCode"] : [],
|
|
20
|
+
...policy.passkey !== "forbidden" ? ["passkey"] : []
|
|
21
|
+
];
|
|
22
|
+
const staffSignIn = auth.operator && policy.staffAccess !== "forbidden" ? auth.operator : void 0;
|
|
23
|
+
return c.json({
|
|
24
|
+
needsAdmin: !await hasUsers(auth),
|
|
25
|
+
signInMethods,
|
|
26
|
+
passwordReset: auth.passwordReset && policy.password === "allowed",
|
|
27
|
+
socialProviders: auth.socialProviders,
|
|
28
|
+
...staffSignIn && { staffSignIn }
|
|
29
|
+
});
|
|
30
|
+
});
|
|
16
31
|
return app;
|
|
17
32
|
};
|
|
18
33
|
//#endregion
|