@shaferllc/keel 0.80.0 → 0.81.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/accounts/accounts.config.stub +50 -0
- package/dist/accounts/config.d.ts +46 -0
- package/dist/accounts/config.js +39 -0
- package/dist/accounts/flows.d.ts +50 -0
- package/dist/accounts/flows.js +133 -0
- package/dist/accounts/index.d.ts +28 -0
- package/dist/accounts/index.js +23 -0
- package/dist/accounts/migration.d.ts +14 -0
- package/dist/accounts/migration.js +39 -0
- package/dist/accounts/provider.d.ts +18 -0
- package/dist/accounts/provider.js +37 -0
- package/dist/accounts/routes.d.ts +15 -0
- package/dist/accounts/routes.js +116 -0
- package/dist/accounts/store.d.ts +33 -0
- package/dist/accounts/store.js +37 -0
- package/dist/accounts/tokens.d.ts +60 -0
- package/dist/accounts/tokens.js +116 -0
- package/dist/accounts/totp.d.ts +58 -0
- package/dist/accounts/totp.js +134 -0
- package/dist/accounts/two-factor.d.ts +56 -0
- package/dist/accounts/two-factor.js +146 -0
- package/dist/core/database.d.ts +36 -0
- package/dist/core/database.js +141 -4
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +3 -2
- package/dist/core/migrations.d.ts +52 -2
- package/dist/core/migrations.js +134 -3
- package/dist/core/model-events.d.ts +34 -0
- package/dist/core/model-events.js +89 -0
- package/dist/core/model-query.d.ts +68 -0
- package/dist/core/model-query.js +234 -0
- package/dist/core/model.d.ts +109 -4
- package/dist/core/model.js +263 -32
- package/dist/core/relations.d.ts +53 -0
- package/dist/core/relations.js +242 -0
- package/dist/teams/config.d.ts +27 -0
- package/dist/teams/config.js +23 -0
- package/dist/teams/context.d.ts +54 -0
- package/dist/teams/context.js +73 -0
- package/dist/teams/index.d.ts +25 -0
- package/dist/teams/index.js +20 -0
- package/dist/teams/invitations.d.ts +38 -0
- package/dist/teams/invitations.js +123 -0
- package/dist/teams/middleware.d.ts +30 -0
- package/dist/teams/middleware.js +92 -0
- package/dist/teams/migration.d.ts +9 -0
- package/dist/teams/migration.js +52 -0
- package/dist/teams/models.d.ts +54 -0
- package/dist/teams/models.js +85 -0
- package/dist/teams/provider.d.ts +17 -0
- package/dist/teams/provider.js +27 -0
- package/dist/teams/teams.config.stub +24 -0
- package/dist/teams/tenant.d.ts +25 -0
- package/dist/teams/tenant.js +45 -0
- package/docs/accounts.md +214 -0
- package/docs/ai-manifest.json +70 -1
- package/docs/database.md +80 -0
- package/docs/examples/accounts.ts +150 -0
- package/docs/examples/teams.ts +101 -0
- package/docs/migrations.md +86 -6
- package/docs/models.md +279 -6
- package/docs/teams.md +176 -0
- package/llms-full.txt +849 -12
- package/llms.txt +4 -0
- package/package.json +10 -2
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { env } from "@shaferllc/keel/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Accounts configuration — password reset, email verification, two-factor.
|
|
5
|
+
* Published with `keel vendor:publish --tag accounts-config`.
|
|
6
|
+
*/
|
|
7
|
+
export default {
|
|
8
|
+
// The users table. Accounts adds four columns to it and touches nothing else.
|
|
9
|
+
userTable: "users",
|
|
10
|
+
|
|
11
|
+
// The JSON endpoints (POST /auth/login, /auth/password/forgot, …).
|
|
12
|
+
// Turn them off to call the flow functions from your own controllers instead.
|
|
13
|
+
routes: { enabled: true, prefix: "auth" },
|
|
14
|
+
|
|
15
|
+
passwordReset: {
|
|
16
|
+
expiresIn: "60m",
|
|
17
|
+
// Where the emailed link points. `:token` is replaced.
|
|
18
|
+
url: "/reset-password?token=:token",
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
verification: {
|
|
22
|
+
expiresIn: "24h",
|
|
23
|
+
url: "/verify-email?token=:token",
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
twoFactor: {
|
|
27
|
+
// The name shown in the user's authenticator app.
|
|
28
|
+
issuer: env("APP_NAME", "Keel"),
|
|
29
|
+
|
|
30
|
+
// Periods of clock drift tolerated either side of now. 1 = ±30s, which is
|
|
31
|
+
// what a phone with a slightly wrong clock needs. Raising it widens the
|
|
32
|
+
// window an attacker gets to guess in.
|
|
33
|
+
window: 1,
|
|
34
|
+
|
|
35
|
+
// How long the gap between "password accepted" and "code entered" stays open.
|
|
36
|
+
// This is the window in which a stolen password alone is enough — keep it short.
|
|
37
|
+
challengeExpiresIn: "5m",
|
|
38
|
+
|
|
39
|
+
recoveryCodes: 8,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Throttling on the credential endpoints. A six-digit code inside a 30-second
|
|
43
|
+
// window is guessable without this, and forgot-password is an email cannon
|
|
44
|
+
// pointed at whoever the caller names.
|
|
45
|
+
rateLimit: { max: 5, window: 60 },
|
|
46
|
+
|
|
47
|
+
mail: {
|
|
48
|
+
from: env("MAIL_FROM", undefined),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts configuration. Defaults live here and are merged under
|
|
3
|
+
* `config("accounts")` by the provider; an app overrides any of them in
|
|
4
|
+
* `config/accounts.ts` (publish it with `keel vendor:publish --tag accounts-config`).
|
|
5
|
+
*/
|
|
6
|
+
export interface AccountsConfig {
|
|
7
|
+
/** The users table. Parameterized so accounts doesn't dictate your schema. */
|
|
8
|
+
userTable: string;
|
|
9
|
+
/** Mount the JSON endpoints. Off if you'd rather call the functions yourself. */
|
|
10
|
+
routes: {
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
prefix: string;
|
|
13
|
+
};
|
|
14
|
+
passwordReset: {
|
|
15
|
+
expiresIn: string;
|
|
16
|
+
/** Where the emailed link points. `:token` is replaced. */
|
|
17
|
+
url: string;
|
|
18
|
+
};
|
|
19
|
+
verification: {
|
|
20
|
+
expiresIn: string;
|
|
21
|
+
url: string;
|
|
22
|
+
};
|
|
23
|
+
twoFactor: {
|
|
24
|
+
issuer: string;
|
|
25
|
+
/** Periods of clock drift to tolerate either side of now. */
|
|
26
|
+
window: number;
|
|
27
|
+
/** How long the post-password, pre-code window stays open. */
|
|
28
|
+
challengeExpiresIn: string;
|
|
29
|
+
recoveryCodes: number;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Throttling for the credential endpoints. Six digits inside a 30-second window
|
|
33
|
+
* is trivially brute-forced without this, and password reset is a free email
|
|
34
|
+
* cannon pointed at whoever you name.
|
|
35
|
+
*/
|
|
36
|
+
rateLimit: {
|
|
37
|
+
max: number;
|
|
38
|
+
window: number;
|
|
39
|
+
};
|
|
40
|
+
mail: {
|
|
41
|
+
from?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export declare const defaultConfig: AccountsConfig;
|
|
45
|
+
/** Read the effective accounts config off the application, filling any gaps. */
|
|
46
|
+
export declare function resolveConfig(): AccountsConfig;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts configuration. Defaults live here and are merged under
|
|
3
|
+
* `config("accounts")` by the provider; an app overrides any of them in
|
|
4
|
+
* `config/accounts.ts` (publish it with `keel vendor:publish --tag accounts-config`).
|
|
5
|
+
*/
|
|
6
|
+
import { config } from "../core/helpers.js";
|
|
7
|
+
export const defaultConfig = {
|
|
8
|
+
userTable: "users",
|
|
9
|
+
routes: { enabled: true, prefix: "auth" },
|
|
10
|
+
passwordReset: {
|
|
11
|
+
expiresIn: "60m",
|
|
12
|
+
url: "/reset-password?token=:token",
|
|
13
|
+
},
|
|
14
|
+
verification: {
|
|
15
|
+
expiresIn: "24h",
|
|
16
|
+
url: "/verify-email?token=:token",
|
|
17
|
+
},
|
|
18
|
+
twoFactor: {
|
|
19
|
+
issuer: "Keel",
|
|
20
|
+
window: 1,
|
|
21
|
+
challengeExpiresIn: "5m",
|
|
22
|
+
recoveryCodes: 8,
|
|
23
|
+
},
|
|
24
|
+
rateLimit: { max: 5, window: 60 },
|
|
25
|
+
mail: {},
|
|
26
|
+
};
|
|
27
|
+
/** Read the effective accounts config off the application, filling any gaps. */
|
|
28
|
+
export function resolveConfig() {
|
|
29
|
+
const raw = config("accounts", {});
|
|
30
|
+
return {
|
|
31
|
+
userTable: raw.userTable ?? defaultConfig.userTable,
|
|
32
|
+
routes: { ...defaultConfig.routes, ...raw.routes },
|
|
33
|
+
passwordReset: { ...defaultConfig.passwordReset, ...raw.passwordReset },
|
|
34
|
+
verification: { ...defaultConfig.verification, ...raw.verification },
|
|
35
|
+
twoFactor: { ...defaultConfig.twoFactor, ...raw.twoFactor },
|
|
36
|
+
rateLimit: { ...defaultConfig.rateLimit, ...raw.rateLimit },
|
|
37
|
+
mail: { ...defaultConfig.mail, ...raw.mail },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account flows. Controllers call these; the views stay yours.
|
|
3
|
+
*
|
|
4
|
+
* Everything security-critical lives here rather than in a template, because a
|
|
5
|
+
* password-reset flow copy-pasted into five starter kits is four copies that
|
|
6
|
+
* quietly rot. Templates render forms and call these six functions.
|
|
7
|
+
*/
|
|
8
|
+
import { type AccountUser } from "./store.js";
|
|
9
|
+
export type LoginResult = {
|
|
10
|
+
status: "ok";
|
|
11
|
+
user: AccountUser;
|
|
12
|
+
}
|
|
13
|
+
/** Password was right; nothing is logged in yet. Send `challenge` back with a code. */
|
|
14
|
+
| {
|
|
15
|
+
status: "two-factor";
|
|
16
|
+
challenge: string;
|
|
17
|
+
} | {
|
|
18
|
+
status: "failed";
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Check a password. On success with 2FA on, this returns a **challenge, not a
|
|
22
|
+
* session** — the caller logs nobody in until `completeTwoFactor()` succeeds.
|
|
23
|
+
*
|
|
24
|
+
* A wrong email and a wrong password are the same answer, and both do the same
|
|
25
|
+
* amount of work: hashing a dummy password when the user doesn't exist keeps the
|
|
26
|
+
* response time from telling an attacker which emails are real.
|
|
27
|
+
*/
|
|
28
|
+
export declare function attempt(email: string, password: string): Promise<LoginResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Finish a 2FA login with either an authenticator code or a recovery code.
|
|
31
|
+
* Returns the user only if the challenge is still valid *and* the code checks out.
|
|
32
|
+
*/
|
|
33
|
+
export declare function completeTwoFactor(challenge: string, code: string): Promise<AccountUser | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Email a reset link — **or quietly do nothing**, if that address has no account.
|
|
36
|
+
*
|
|
37
|
+
* This never reveals whether the email exists. "No account with that address" is a
|
|
38
|
+
* free account-enumeration oracle, and the endpoint is unauthenticated, so anyone
|
|
39
|
+
* can ask it about anyone. The caller gets the same answer either way.
|
|
40
|
+
*/
|
|
41
|
+
export declare function requestPasswordReset(email: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Spend a reset token. Returns false if it's expired, forged, or already used —
|
|
44
|
+
* "already used" falls out of the token being bound to the old password hash, so
|
|
45
|
+
* the same link cannot set a password twice.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resetPassword(token: string, password: string): Promise<boolean>;
|
|
48
|
+
export declare function sendVerificationEmail(user: AccountUser): Promise<void>;
|
|
49
|
+
/** Mark the address proven. Idempotent — clicking the link twice is not an error. */
|
|
50
|
+
export declare function verifyEmail(token: string): Promise<AccountUser | null>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account flows. Controllers call these; the views stay yours.
|
|
3
|
+
*
|
|
4
|
+
* Everything security-critical lives here rather than in a template, because a
|
|
5
|
+
* password-reset flow copy-pasted into five starter kits is four copies that
|
|
6
|
+
* quietly rot. Templates render forms and call these six functions.
|
|
7
|
+
*/
|
|
8
|
+
import { hash } from "../core/crypto.js";
|
|
9
|
+
import { config } from "../core/helpers.js";
|
|
10
|
+
import { mail } from "../core/mail.js";
|
|
11
|
+
import { resolveConfig } from "./config.js";
|
|
12
|
+
import { accountStore } from "./store.js";
|
|
13
|
+
import { emailVerificationToken, passwordResetToken, twoFactorChallenge, verifyEmailToken, verifyPasswordResetToken, verifyTwoFactorChallenge, } from "./tokens.js";
|
|
14
|
+
import { hasTwoFactor, redeemRecoveryCode, verifyTwoFactorCode } from "./two-factor.js";
|
|
15
|
+
/**
|
|
16
|
+
* Check a password. On success with 2FA on, this returns a **challenge, not a
|
|
17
|
+
* session** — the caller logs nobody in until `completeTwoFactor()` succeeds.
|
|
18
|
+
*
|
|
19
|
+
* A wrong email and a wrong password are the same answer, and both do the same
|
|
20
|
+
* amount of work: hashing a dummy password when the user doesn't exist keeps the
|
|
21
|
+
* response time from telling an attacker which emails are real.
|
|
22
|
+
*/
|
|
23
|
+
export async function attempt(email, password) {
|
|
24
|
+
const config = resolveConfig();
|
|
25
|
+
const user = await accountStore().findByEmail(email);
|
|
26
|
+
// `hash.dummy` when there's no user, so a missing account costs the same PBKDF2
|
|
27
|
+
// as a wrong password. A fast "no such user" is a free enumeration oracle.
|
|
28
|
+
// `user &&` guards the dummy from ever authenticating anyone.
|
|
29
|
+
const ok = await hash.verify(user?.password ?? hash.dummy, password);
|
|
30
|
+
if (!ok || !user)
|
|
31
|
+
return { status: "failed" };
|
|
32
|
+
if (hasTwoFactor(user)) {
|
|
33
|
+
return {
|
|
34
|
+
status: "two-factor",
|
|
35
|
+
challenge: await twoFactorChallenge(user, config.twoFactor.challengeExpiresIn),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { status: "ok", user };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Finish a 2FA login with either an authenticator code or a recovery code.
|
|
42
|
+
* Returns the user only if the challenge is still valid *and* the code checks out.
|
|
43
|
+
*/
|
|
44
|
+
export async function completeTwoFactor(challenge, code) {
|
|
45
|
+
const config = resolveConfig();
|
|
46
|
+
const store = accountStore();
|
|
47
|
+
const user = await verifyTwoFactorChallenge(challenge, (id) => store.findById(id));
|
|
48
|
+
if (!user)
|
|
49
|
+
return null;
|
|
50
|
+
// A recovery code has a dash and is longer; a TOTP code is six digits. Try the
|
|
51
|
+
// authenticator first — it's what almost everyone uses.
|
|
52
|
+
if (await verifyTwoFactorCode(user, code, { window: config.twoFactor.window }))
|
|
53
|
+
return user;
|
|
54
|
+
if (await redeemRecoveryCode(user, code))
|
|
55
|
+
return user;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/* ------------------------------ password reset ---------------------------- */
|
|
59
|
+
/**
|
|
60
|
+
* Email a reset link — **or quietly do nothing**, if that address has no account.
|
|
61
|
+
*
|
|
62
|
+
* This never reveals whether the email exists. "No account with that address" is a
|
|
63
|
+
* free account-enumeration oracle, and the endpoint is unauthenticated, so anyone
|
|
64
|
+
* can ask it about anyone. The caller gets the same answer either way.
|
|
65
|
+
*/
|
|
66
|
+
export async function requestPasswordReset(email) {
|
|
67
|
+
const config = resolveConfig();
|
|
68
|
+
const user = await accountStore().findByEmail(email);
|
|
69
|
+
if (!user)
|
|
70
|
+
return;
|
|
71
|
+
const token = await passwordResetToken(user, config.passwordReset.expiresIn);
|
|
72
|
+
const link = absolute(config.passwordReset.url.replace(":token", encodeURIComponent(token)));
|
|
73
|
+
const message = mail()
|
|
74
|
+
.to(user.email)
|
|
75
|
+
.subject("Reset your password")
|
|
76
|
+
.html(`<p>Someone asked to reset your password.</p>` +
|
|
77
|
+
`<p><a href="${link}">Choose a new password</a></p>` +
|
|
78
|
+
`<p>The link expires in ${config.passwordReset.expiresIn}. If this wasn't you, ignore it — nothing has changed.</p>`);
|
|
79
|
+
if (config.mail.from)
|
|
80
|
+
message.from(config.mail.from);
|
|
81
|
+
await message.send();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Spend a reset token. Returns false if it's expired, forged, or already used —
|
|
85
|
+
* "already used" falls out of the token being bound to the old password hash, so
|
|
86
|
+
* the same link cannot set a password twice.
|
|
87
|
+
*/
|
|
88
|
+
export async function resetPassword(token, password) {
|
|
89
|
+
const store = accountStore();
|
|
90
|
+
const user = await verifyPasswordResetToken(token, (id) => store.findById(id));
|
|
91
|
+
if (!user)
|
|
92
|
+
return false;
|
|
93
|
+
await store.update(user.id, { password: await hash.make(password) });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
/* --------------------------- email verification --------------------------- */
|
|
97
|
+
export async function sendVerificationEmail(user) {
|
|
98
|
+
const config = resolveConfig();
|
|
99
|
+
const token = await emailVerificationToken(user, config.verification.expiresIn);
|
|
100
|
+
const link = absolute(config.verification.url.replace(":token", encodeURIComponent(token)));
|
|
101
|
+
const message = mail()
|
|
102
|
+
.to(user.email)
|
|
103
|
+
.subject("Confirm your email address")
|
|
104
|
+
.html(`<p>Confirm your address to finish setting up your account.</p>
|
|
105
|
+
<p><a href="${link}">Confirm ${user.email}</a></p>`);
|
|
106
|
+
if (config.mail.from)
|
|
107
|
+
message.from(config.mail.from);
|
|
108
|
+
await message.send();
|
|
109
|
+
}
|
|
110
|
+
/** Mark the address proven. Idempotent — clicking the link twice is not an error. */
|
|
111
|
+
export async function verifyEmail(token) {
|
|
112
|
+
const store = accountStore();
|
|
113
|
+
const user = await verifyEmailToken(token, (id) => store.findById(id));
|
|
114
|
+
if (!user)
|
|
115
|
+
return null;
|
|
116
|
+
if (!user.email_verified_at) {
|
|
117
|
+
await store.update(user.id, { email_verified_at: new Date().toISOString() });
|
|
118
|
+
}
|
|
119
|
+
return user;
|
|
120
|
+
}
|
|
121
|
+
/* --------------------------------- helpers -------------------------------- */
|
|
122
|
+
/**
|
|
123
|
+
* Leave absolute URLs alone; make relative ones absolute against `app.url`.
|
|
124
|
+
*
|
|
125
|
+
* Read through `config()`, not `process.env` — this module has to run on Workers,
|
|
126
|
+
* where there is no `process`.
|
|
127
|
+
*/
|
|
128
|
+
function absolute(url) {
|
|
129
|
+
if (/^https?:\/\//i.test(url))
|
|
130
|
+
return url;
|
|
131
|
+
const base = config("app.url", "http://localhost:3000").replace(/\/$/, "");
|
|
132
|
+
return `${base}${url.startsWith("/") ? "" : "/"}${url}`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Accounts — public surface, imported from `@shaferllc/keel/accounts`.
|
|
3
|
+
*
|
|
4
|
+
* import { AccountsServiceProvider, attempt } from "@shaferllc/keel/accounts";
|
|
5
|
+
*
|
|
6
|
+
* Password reset, email verification, and two-factor: the flows every app with a
|
|
7
|
+
* login needs, built on the primitives already in core (`hash`, `encryption` with
|
|
8
|
+
* purpose + expiry, `mail`, `rate-limit`). They live here, tested once, instead of
|
|
9
|
+
* being copy-pasted into every starter kit — four copies of a security flow are
|
|
10
|
+
* four copies that quietly rot.
|
|
11
|
+
*
|
|
12
|
+
* Views stay yours. These are functions and JSON endpoints; your controllers
|
|
13
|
+
* render the forms.
|
|
14
|
+
*/
|
|
15
|
+
export { AccountsServiceProvider } from "./provider.js";
|
|
16
|
+
export { attempt, completeTwoFactor, requestPasswordReset, resetPassword, sendVerificationEmail, verifyEmail, } from "./flows.js";
|
|
17
|
+
export type { LoginResult } from "./flows.js";
|
|
18
|
+
export { confirmTwoFactor, disableTwoFactor, enableTwoFactor, hasTwoFactor, recoveryCodesRemaining, redeemRecoveryCode, regenerateRecoveryCodes, verifyTwoFactorCode, } from "./two-factor.js";
|
|
19
|
+
export type { TwoFactorOptions, TwoFactorSetup } from "./two-factor.js";
|
|
20
|
+
export { base32Decode, base32Encode, generateSecret, otpauthUri, totp, verifyTotp, } from "./totp.js";
|
|
21
|
+
export type { OtpauthOptions, TotpOptions, VerifyOptions } from "./totp.js";
|
|
22
|
+
export { emailVerificationToken, passwordResetToken, twoFactorChallenge, verifyEmailToken, verifyPasswordResetToken, verifyTwoFactorChallenge, PURPOSE, } from "./tokens.js";
|
|
23
|
+
export { accountStore, setAccountStore, tableStore } from "./store.js";
|
|
24
|
+
export type { AccountStore, AccountUser } from "./store.js";
|
|
25
|
+
export { registerAccountsRoutes } from "./routes.js";
|
|
26
|
+
export { accountsMigration } from "./migration.js";
|
|
27
|
+
export { defaultConfig, resolveConfig } from "./config.js";
|
|
28
|
+
export type { AccountsConfig } from "./config.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keel Accounts — public surface, imported from `@shaferllc/keel/accounts`.
|
|
3
|
+
*
|
|
4
|
+
* import { AccountsServiceProvider, attempt } from "@shaferllc/keel/accounts";
|
|
5
|
+
*
|
|
6
|
+
* Password reset, email verification, and two-factor: the flows every app with a
|
|
7
|
+
* login needs, built on the primitives already in core (`hash`, `encryption` with
|
|
8
|
+
* purpose + expiry, `mail`, `rate-limit`). They live here, tested once, instead of
|
|
9
|
+
* being copy-pasted into every starter kit — four copies of a security flow are
|
|
10
|
+
* four copies that quietly rot.
|
|
11
|
+
*
|
|
12
|
+
* Views stay yours. These are functions and JSON endpoints; your controllers
|
|
13
|
+
* render the forms.
|
|
14
|
+
*/
|
|
15
|
+
export { AccountsServiceProvider } from "./provider.js";
|
|
16
|
+
export { attempt, completeTwoFactor, requestPasswordReset, resetPassword, sendVerificationEmail, verifyEmail, } from "./flows.js";
|
|
17
|
+
export { confirmTwoFactor, disableTwoFactor, enableTwoFactor, hasTwoFactor, recoveryCodesRemaining, redeemRecoveryCode, regenerateRecoveryCodes, verifyTwoFactorCode, } from "./two-factor.js";
|
|
18
|
+
export { base32Decode, base32Encode, generateSecret, otpauthUri, totp, verifyTotp, } from "./totp.js";
|
|
19
|
+
export { emailVerificationToken, passwordResetToken, twoFactorChallenge, verifyEmailToken, verifyPasswordResetToken, verifyTwoFactorChallenge, PURPOSE, } from "./tokens.js";
|
|
20
|
+
export { accountStore, setAccountStore, tableStore } from "./store.js";
|
|
21
|
+
export { registerAccountsRoutes } from "./routes.js";
|
|
22
|
+
export { accountsMigration } from "./migration.js";
|
|
23
|
+
export { defaultConfig, resolveConfig } from "./config.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounts schema — four columns on the users table, and no tokens table.
|
|
3
|
+
*
|
|
4
|
+
* There is deliberately no `password_resets` table. Reset and verification tokens
|
|
5
|
+
* carry their own purpose and expiry inside the ciphertext (see tokens.ts), so
|
|
6
|
+
* there is nothing to insert, nothing to look up, and nothing to garbage-collect —
|
|
7
|
+
* and no window where a forgotten cleanup job leaves a live token in a row.
|
|
8
|
+
*
|
|
9
|
+
* Columns are added with `schema.raw()` because the builder has no `alterTable`,
|
|
10
|
+
* and the SQL is kept to the intersection sqlite, mysql, and postgres all accept —
|
|
11
|
+
* the same approach as `billingMigration`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Migration } from "../core/migrations.js";
|
|
14
|
+
export declare function accountsMigration(userTable?: string): Migration;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounts schema — four columns on the users table, and no tokens table.
|
|
3
|
+
*
|
|
4
|
+
* There is deliberately no `password_resets` table. Reset and verification tokens
|
|
5
|
+
* carry their own purpose and expiry inside the ciphertext (see tokens.ts), so
|
|
6
|
+
* there is nothing to insert, nothing to look up, and nothing to garbage-collect —
|
|
7
|
+
* and no window where a forgotten cleanup job leaves a live token in a row.
|
|
8
|
+
*
|
|
9
|
+
* Columns are added with `schema.raw()` because the builder has no `alterTable`,
|
|
10
|
+
* and the SQL is kept to the intersection sqlite, mysql, and postgres all accept —
|
|
11
|
+
* the same approach as `billingMigration`.
|
|
12
|
+
*/
|
|
13
|
+
const COLUMNS = [
|
|
14
|
+
"email_verified_at TIMESTAMP",
|
|
15
|
+
// Encrypted at rest: a database leak must not hand over everyone's second factor.
|
|
16
|
+
"two_factor_secret TEXT",
|
|
17
|
+
// Hashed, single-use, JSON — then encrypted, same reasoning.
|
|
18
|
+
"two_factor_recovery_codes TEXT",
|
|
19
|
+
// Null until a working code proves the user can actually generate one.
|
|
20
|
+
"two_factor_confirmed_at TIMESTAMP",
|
|
21
|
+
];
|
|
22
|
+
export function accountsMigration(userTable = "users") {
|
|
23
|
+
return {
|
|
24
|
+
name: "accounts_00_add_account_columns",
|
|
25
|
+
async up(schema) {
|
|
26
|
+
for (const column of COLUMNS) {
|
|
27
|
+
await schema.raw(`ALTER TABLE ${userTable} ADD COLUMN ${column}`);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async down(schema) {
|
|
31
|
+
for (const column of COLUMNS) {
|
|
32
|
+
const name = column.split(" ")[0];
|
|
33
|
+
// sqlite couldn't drop columns until 3.35; a failed drop shouldn't wedge
|
|
34
|
+
// a rollback of everything else.
|
|
35
|
+
await schema.raw(`ALTER TABLE ${userTable} DROP COLUMN ${name}`).catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts — password reset, email verification, and two-factor, shipped as a Keel
|
|
3
|
+
* package. One line in `bootstrap/providers.ts` turns it on:
|
|
4
|
+
*
|
|
5
|
+
* app.register(AccountsServiceProvider)
|
|
6
|
+
*
|
|
7
|
+
* `register()` merges config, installs the store, and contributes the migration.
|
|
8
|
+
* `boot()` mounts the JSON endpoints (unless you've turned them off and would
|
|
9
|
+
* rather call the flow functions from your own controllers).
|
|
10
|
+
*/
|
|
11
|
+
import { PackageProvider } from "../core/package.js";
|
|
12
|
+
export declare class AccountsServiceProvider extends PackageProvider {
|
|
13
|
+
readonly name = "accounts";
|
|
14
|
+
private config;
|
|
15
|
+
register(): void;
|
|
16
|
+
boot(): void;
|
|
17
|
+
shutdown(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts — password reset, email verification, and two-factor, shipped as a Keel
|
|
3
|
+
* package. One line in `bootstrap/providers.ts` turns it on:
|
|
4
|
+
*
|
|
5
|
+
* app.register(AccountsServiceProvider)
|
|
6
|
+
*
|
|
7
|
+
* `register()` merges config, installs the store, and contributes the migration.
|
|
8
|
+
* `boot()` mounts the JSON endpoints (unless you've turned them off and would
|
|
9
|
+
* rather call the flow functions from your own controllers).
|
|
10
|
+
*/
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { PackageProvider } from "../core/package.js";
|
|
14
|
+
import { defaultConfig, resolveConfig } from "./config.js";
|
|
15
|
+
import { accountsMigration } from "./migration.js";
|
|
16
|
+
import { registerAccountsRoutes } from "./routes.js";
|
|
17
|
+
import { setAccountStore, tableStore } from "./store.js";
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
export class AccountsServiceProvider extends PackageProvider {
|
|
20
|
+
name = "accounts";
|
|
21
|
+
config;
|
|
22
|
+
register() {
|
|
23
|
+
this.mergeConfig("accounts", defaultConfig);
|
|
24
|
+
this.config = resolveConfig();
|
|
25
|
+
setAccountStore(tableStore(this.config.userTable));
|
|
26
|
+
this.migrations([accountsMigration(this.config.userTable)]);
|
|
27
|
+
this.publishes({ [join(here, "accounts.config.stub")]: "config/accounts.ts" }, "accounts-config");
|
|
28
|
+
}
|
|
29
|
+
boot() {
|
|
30
|
+
if (!this.config.routes.enabled)
|
|
31
|
+
return;
|
|
32
|
+
this.routes((r) => registerAccountsRoutes(r, this.config));
|
|
33
|
+
}
|
|
34
|
+
shutdown() {
|
|
35
|
+
setAccountStore(undefined);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounts HTTP surface — JSON endpoints, no views.
|
|
3
|
+
*
|
|
4
|
+
* Core cannot render your login page and shouldn't try: views are the app's, and
|
|
5
|
+
* this module has to run on Workers. So these endpoints do the security-critical
|
|
6
|
+
* part and return JSON; a full-stack template wraps them in controllers that
|
|
7
|
+
* render forms, and an API template mounts them as-is.
|
|
8
|
+
*
|
|
9
|
+
* Every endpoint here is unauthenticated and touches credentials, so the whole
|
|
10
|
+
* group is rate-limited. Without it, password reset is an email cannon you point
|
|
11
|
+
* at whoever you name, and a six-digit 2FA code is guessable in an afternoon.
|
|
12
|
+
*/
|
|
13
|
+
import type { Router } from "../core/http/router.js";
|
|
14
|
+
import type { AccountsConfig } from "./config.js";
|
|
15
|
+
export declare function registerAccountsRoutes(r: Router, config: AccountsConfig): void;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounts HTTP surface — JSON endpoints, no views.
|
|
3
|
+
*
|
|
4
|
+
* Core cannot render your login page and shouldn't try: views are the app's, and
|
|
5
|
+
* this module has to run on Workers. So these endpoints do the security-critical
|
|
6
|
+
* part and return JSON; a full-stack template wraps them in controllers that
|
|
7
|
+
* render forms, and an API template mounts them as-is.
|
|
8
|
+
*
|
|
9
|
+
* Every endpoint here is unauthenticated and touches credentials, so the whole
|
|
10
|
+
* group is rate-limited. Without it, password reset is an email cannon you point
|
|
11
|
+
* at whoever you name, and a six-digit 2FA code is guessable in an afternoon.
|
|
12
|
+
*/
|
|
13
|
+
import { rateLimiter } from "../core/rate-limit.js";
|
|
14
|
+
import { attempt, completeTwoFactor, requestPasswordReset, resetPassword, sendVerificationEmail, verifyEmail, } from "./flows.js";
|
|
15
|
+
export function registerAccountsRoutes(r, config) {
|
|
16
|
+
const base = "/" + config.routes.prefix.replace(/^\/|\/$/g, "");
|
|
17
|
+
const limit = rateLimiter({ max: config.rateLimit.max, window: config.rateLimit.window });
|
|
18
|
+
/* ---------------------------------- login --------------------------------- */
|
|
19
|
+
r.post(`${base}/login`, async (c) => {
|
|
20
|
+
const { email, password } = await body(c);
|
|
21
|
+
if (!email || !password)
|
|
22
|
+
return c.json({ error: "Email and password are required." }, 422);
|
|
23
|
+
const result = await attempt(email, password);
|
|
24
|
+
if (result.status === "failed") {
|
|
25
|
+
// One message for a wrong email and a wrong password. Anything more specific
|
|
26
|
+
// tells an attacker which addresses have accounts.
|
|
27
|
+
return c.json({ error: "Those credentials don't match." }, 401);
|
|
28
|
+
}
|
|
29
|
+
if (result.status === "two-factor") {
|
|
30
|
+
// Note what this is NOT: a session. Nothing is logged in yet.
|
|
31
|
+
return c.json({ twoFactor: true, challenge: result.challenge }, 200);
|
|
32
|
+
}
|
|
33
|
+
return c.json({ user: publicUser(result.user) }, 200);
|
|
34
|
+
})
|
|
35
|
+
.middleware(limit)
|
|
36
|
+
.name("accounts.login");
|
|
37
|
+
r.post(`${base}/two-factor`, async (c) => {
|
|
38
|
+
const { challenge, code } = await body(c);
|
|
39
|
+
if (!challenge || !code)
|
|
40
|
+
return c.json({ error: "A challenge and a code are required." }, 422);
|
|
41
|
+
const user = await completeTwoFactor(challenge, code);
|
|
42
|
+
if (!user)
|
|
43
|
+
return c.json({ error: "That code isn't valid." }, 401);
|
|
44
|
+
return c.json({ user: publicUser(user) }, 200);
|
|
45
|
+
})
|
|
46
|
+
.middleware(limit)
|
|
47
|
+
.name("accounts.two-factor");
|
|
48
|
+
/* ------------------------------ password reset ---------------------------- */
|
|
49
|
+
r.post(`${base}/password/forgot`, async (c) => {
|
|
50
|
+
const { email } = await body(c);
|
|
51
|
+
if (!email)
|
|
52
|
+
return c.json({ error: "An email is required." }, 422);
|
|
53
|
+
await requestPasswordReset(email);
|
|
54
|
+
// 202 whether or not that address has an account. The response must not be an
|
|
55
|
+
// oracle for which emails are registered.
|
|
56
|
+
return c.json({ status: "If that address has an account, a link is on its way." }, 202);
|
|
57
|
+
})
|
|
58
|
+
.middleware(limit)
|
|
59
|
+
.name("accounts.password.forgot");
|
|
60
|
+
r.post(`${base}/password/reset`, async (c) => {
|
|
61
|
+
const { token, password } = await body(c);
|
|
62
|
+
if (!token || !password)
|
|
63
|
+
return c.json({ error: "A token and a password are required." }, 422);
|
|
64
|
+
const ok = await resetPassword(token, password);
|
|
65
|
+
if (!ok)
|
|
66
|
+
return c.json({ error: "That reset link is invalid or has expired." }, 422);
|
|
67
|
+
return c.json({ status: "Your password has been reset." }, 200);
|
|
68
|
+
})
|
|
69
|
+
.middleware(limit)
|
|
70
|
+
.name("accounts.password.reset");
|
|
71
|
+
/* --------------------------- email verification --------------------------- */
|
|
72
|
+
r.post(`${base}/email/verify`, async (c) => {
|
|
73
|
+
const { token } = await body(c);
|
|
74
|
+
if (!token)
|
|
75
|
+
return c.json({ error: "A token is required." }, 422);
|
|
76
|
+
const user = await verifyEmail(token);
|
|
77
|
+
if (!user)
|
|
78
|
+
return c.json({ error: "That link is invalid or has expired." }, 422);
|
|
79
|
+
return c.json({ status: "Your email is confirmed.", user: publicUser(user) }, 200);
|
|
80
|
+
})
|
|
81
|
+
.middleware(limit)
|
|
82
|
+
.name("accounts.email.verify");
|
|
83
|
+
r.post(`${base}/email/resend`, async (c) => {
|
|
84
|
+
const { email } = await body(c);
|
|
85
|
+
if (!email)
|
|
86
|
+
return c.json({ error: "An email is required." }, 422);
|
|
87
|
+
const { accountStore } = await import("./store.js");
|
|
88
|
+
const user = await accountStore().findByEmail(email);
|
|
89
|
+
// Same reasoning as forgot-password: don't confirm whether the account exists.
|
|
90
|
+
if (user && !user.email_verified_at)
|
|
91
|
+
await sendVerificationEmail(user);
|
|
92
|
+
return c.json({ status: "If that address needs confirming, a link is on its way." }, 202);
|
|
93
|
+
})
|
|
94
|
+
.middleware(limit)
|
|
95
|
+
.name("accounts.email.resend");
|
|
96
|
+
}
|
|
97
|
+
/* --------------------------------- helpers -------------------------------- */
|
|
98
|
+
async function body(c) {
|
|
99
|
+
try {
|
|
100
|
+
return (await c.req.json());
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* What's safe to hand back. Explicitly allow-listed rather than deleting the
|
|
108
|
+
* secrets — a deny-list means the next column someone adds leaks by default.
|
|
109
|
+
*/
|
|
110
|
+
function publicUser(user) {
|
|
111
|
+
return {
|
|
112
|
+
id: user.id,
|
|
113
|
+
email: user.email,
|
|
114
|
+
emailVerified: Boolean(user.email_verified_at),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where accounts reads and writes users.
|
|
3
|
+
*
|
|
4
|
+
* The module refuses to assume you use a `Model` — it talks to a table through
|
|
5
|
+
* the query builder, exactly like `billing` parameterizes its billable table. If
|
|
6
|
+
* your users live somewhere else entirely (an auth service, a legacy schema),
|
|
7
|
+
* replace the whole thing:
|
|
8
|
+
*
|
|
9
|
+
* setAccountStore({ findById, findByEmail, update });
|
|
10
|
+
*/
|
|
11
|
+
import { type Row } from "../core/database.js";
|
|
12
|
+
/** The columns accounts needs. Everything else on your users table is untouched. */
|
|
13
|
+
export interface AccountUser extends Row {
|
|
14
|
+
id: string | number;
|
|
15
|
+
email: string;
|
|
16
|
+
/** The hashed password. Never the plaintext. */
|
|
17
|
+
password?: string | null;
|
|
18
|
+
email_verified_at?: string | null;
|
|
19
|
+
/** The TOTP secret, encrypted at rest. */
|
|
20
|
+
two_factor_secret?: string | null;
|
|
21
|
+
/** Hashed, single-use recovery codes, as a JSON array. */
|
|
22
|
+
two_factor_recovery_codes?: string | null;
|
|
23
|
+
two_factor_confirmed_at?: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface AccountStore {
|
|
26
|
+
findById(id: string | number): Promise<AccountUser | null>;
|
|
27
|
+
findByEmail(email: string): Promise<AccountUser | null>;
|
|
28
|
+
update(id: string | number, values: Row): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** The default store: a table, through the query builder. */
|
|
31
|
+
export declare function tableStore(table: string): AccountStore;
|
|
32
|
+
export declare function setAccountStore(next: AccountStore | undefined): void;
|
|
33
|
+
export declare function accountStore(): AccountStore;
|