@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.
@@ -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
- * `passwordReset` when reset mail can be sent, so it offers "Forgot password?" only then, and the ids of the configured
7
- * `socialProviders`, so it offers "Continue with GitHub" when `github` is one.
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) => c.json({
12
- needsAdmin: !await hasUsers(auth),
13
- passwordReset: auth.passwordReset,
14
- socialProviders: auth.socialProviders
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
@@ -1,4 +1,4 @@
1
- import { parseRoles } from "./users.js";
1
+ import { parseRoles } from "./parse-roles.js";
2
2
  //#region src/better-auth/tokens.ts
3
3
  const defaultTtlSeconds = 900;
4
4
  const maxTokenTtlSeconds = 86400;