@krizaka/auth 0.8.0 → 0.9.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/README.md +33 -0
- package/dist/chunk-3CLSDLXB.js +204 -0
- package/dist/chunk-3CLSDLXB.js.map +1 -0
- package/dist/{chunk-U5SDV7YV.js → chunk-5CFOMWBN.js} +25 -1
- package/dist/{chunk-U5SDV7YV.js.map → chunk-5CFOMWBN.js.map} +1 -1
- package/dist/contract.d.ts +46 -0
- package/dist/contract.js +27 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +136 -3
- package/dist/index.js +211 -188
- package/dist/index.js.map +1 -1
- package/dist/{identity-links-BI3DYE2I.d.ts → sessions-B9YKXs0i.d.ts} +97 -2
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +17 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +15 -2
- package/dist/testkit.js +62 -8
- package/dist/testkit.js.map +1 -1
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -41,3 +41,36 @@ const result = await signIn.signIn(await federation.complete(code)); // { status
|
|
|
41
41
|
An external identity attaches to an existing account **only when the address is verified on both sides** (by the provider and by the product); otherwise it is refused (`EMAIL_NOT_VERIFIED`, `ACCOUNT_EMAIL_UNVERIFIED`), and it is never a second user table. `create` is optional: leave it out and a person without an account is refused (`NO_ACCOUNT`). Adapters run the conformance kits of `@krizaka/auth/testkit` (`identityProviderContract`, `identityLinkStoreContract`).
|
|
42
42
|
|
|
43
43
|
> `postgresAccounts` was the first name of `dbAccounts`; it stays as a `@deprecated` alias for one minor and leaves in the next major.
|
|
44
|
+
|
|
45
|
+
## Sessions, passwords, addresses and the administration's locks
|
|
46
|
+
|
|
47
|
+
What Yomizaka, Akinaza, Kachizaka and Yororis each copied (`password.ts`, `client-ip.ts`, `session.ts`, the admin gate of `access.ts`) is here once. Each piece is a function of its inputs: the product reads its environment and its framework, and passes values.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { allowedIps, clientIp, createSessions, dbSessionStore, hashPassword, requireAdmin, sessionCookie, sessionTokenOf, verifyPassword } from "@krizaka/auth";
|
|
51
|
+
|
|
52
|
+
const sessions = createSessions({
|
|
53
|
+
store: dbSessionStore(query, { columns: ["id", "email", "role", "locale"], toAccount: (row) => ({ id: String(row.id), email: String(row.email), role: row.role as "USER" | "ADMIN", locale: row.locale as "en" | "fr" }), active: { column: "status", equals: "ACTIVE" } }),
|
|
54
|
+
clock: { now: () => new Date() },
|
|
55
|
+
tokenPrefix: "ys", // tokens are `ys1.<43 characters>`
|
|
56
|
+
secret: () => env.AUTH_SESSION_SECRET, // keys the stored hash (HMAC-SHA256)
|
|
57
|
+
sessionDays: () => settings.get("auth.sessionDays"), // the setting, read for each new session
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ip = clientIp(request.headers, { header: env.ABUSE_CLIENT_IP_HEADER, trustedProxyHops: env.ABUSE_TRUSTED_PROXY_HOPS });
|
|
61
|
+
const { token, expires } = await sessions.create(account.id, { ip, userAgent: request.headers.get("user-agent") });
|
|
62
|
+
cookies().set(sessionCookie(token, expires, { name: "yomizaka_session", secure: production }));
|
|
63
|
+
|
|
64
|
+
const session = await sessions.resolve(sessionTokenOf(request.headers, { cookie: "yomizaka_session" })); // bearer of a native app, else the cookie
|
|
65
|
+
const verdict = requireAdmin({ session, ip, allowedIps: allowedIps(env.AUTH_ALLOWED_IPS, { production }), surface: "api" }); // { ok: true } | { ok: false, status, code }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **Passwords:** `hashPassword` and `verifyPassword` are scrypt (N=2^15, r=8, p=1), stored `scrypt$N$r$p$salt$hash`, the format of the copies: a hash a product stored verifies here (a test pins one). `passwordProblem` (12 to 256 characters) and `decoyHash` (verify against it when the address has no account: the same work, the same time). Hand them to `dbAccounts({ passwords: { hash: hashPassword, verify: verifyPassword } })`.
|
|
69
|
+
- **Client address:** `clientIp(headers, { header, trustedProxyHops })` reads the header the edge sets, else the entry `trustedProxyHops` from the right of `X-Forwarded-For` (never the left: the client writes it), else `X-Real-IP`, else null.
|
|
70
|
+
- **Sessions:** a `SessionStore` port with `dbSessionStore` (the product's `sessions` table, declared as parameters: the table names and the account columns are checked, and a suspended account signs nobody in at once), `memorySessionStore` for tests, and `sessionsSchema` for a product without the table. The token is shown once; only its HMAC is stored, with the same formula as the copies, so sessions already stored keep working. A session lasts `sessionDays` of 24 hours from the clock.
|
|
71
|
+
- **The administration's locks:** `requireAdmin({ session, ip, allowedIps, surface })` decides on the three locks (a session, the ADMIN role, an address on the list) and answers the status and code: on the API 401 or 403 with `UNAUTHENTICATED`, `FORBIDDEN` or `IP_NOT_ALLOWED`; on a page 404 for every failed lock, because the administration does not announce itself. `allowedIps(list, { production })` makes an empty list « nobody » in production and « anybody » in development.
|
|
72
|
+
- **`@krizaka/auth/contract`** (browser-safe, needs `zod`): `GET /api/me` answers **200** `{ signedIn, signupOpen, account? }` with `Cache-Control: private, no-store` and `Vary: Cookie` (krizaka-ui#551). Signed out is not an error. `meResponse({ signupOpen, account })` builds the answer and checks it against `meSchema`; the bar island of `@krizaka/ui` reads the same file.
|
|
73
|
+
- **`@krizaka/auth/settings`** (needs `@krizaka/settings`): the registry fragment `authSettings`: `auth.sessionDays` (14, 1 to 90) and `auth.passwordResetMinutes` (30, 5 to 1440).
|
|
74
|
+
- **Forgotten password:** `dbAccounts` already is the reset port: `requestPasswordReset(email, now)` (the same answer whether or not the address has an account, the link mailed in the person's language) and `resetPassword(token, next, now)` (the token is stored hashed, used once, expires after `auth.passwordResetMinutes`, and every session ends). Put the product's limiter in front of both routes (`@krizaka/rate-limit`).
|
|
75
|
+
|
|
76
|
+
`sessionStoreContract` (from `@krizaka/auth/testkit`) is the contract every `SessionStore` adapter runs, memory and database alike.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// src/port.ts
|
|
2
|
+
var AccountError = class extends Error {
|
|
3
|
+
constructor(status, code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "AccountError";
|
|
8
|
+
}
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/accounts.ts
|
|
14
|
+
import crypto from "crypto";
|
|
15
|
+
import { addHours, addMinutes } from "date-fns";
|
|
16
|
+
var EMAIL_CHANGE_TTL_HOURS = 24;
|
|
17
|
+
var PASSWORD_RESET_TTL_MINUTES = 30;
|
|
18
|
+
var PASSWORD_RESET_MIN_MINUTES = 5;
|
|
19
|
+
var PASSWORD_RESET_MAX_MINUTES = 1440;
|
|
20
|
+
var checkResetTtl = (minutes) => {
|
|
21
|
+
if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {
|
|
22
|
+
throw new RangeError(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);
|
|
23
|
+
}
|
|
24
|
+
return minutes;
|
|
25
|
+
};
|
|
26
|
+
var NAME_MAX = 80;
|
|
27
|
+
var PASSWORD_MIN = 12;
|
|
28
|
+
var PASSWORD_MAX = 256;
|
|
29
|
+
var THEMES = ["light", "dark", "system"];
|
|
30
|
+
var linkToken = (prefix) => `${prefix}1.${crypto.randomBytes(32).toString("base64url")}`;
|
|
31
|
+
var isLinkToken = (prefix, value) => !!value && new RegExp(`^${prefix}1\\.[A-Za-z0-9_-]{43}$`).test(value);
|
|
32
|
+
var emailChangeToken = linkToken;
|
|
33
|
+
var isEmailChangeToken = isLinkToken;
|
|
34
|
+
function passwordResetMail(product, locale, url, ttlMinutes = PASSWORD_RESET_TTL_MINUTES) {
|
|
35
|
+
checkResetTtl(ttlMinutes);
|
|
36
|
+
return locale === "fr" ? { subject: `R\xE9initialisez votre mot de passe \u2014 ${product}`, text: `Bonjour,
|
|
37
|
+
|
|
38
|
+
Pour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :
|
|
39
|
+
${url}
|
|
40
|
+
|
|
41
|
+
Si vous n'avez rien demand\xE9, ignorez ce message : votre mot de passe ne change pas.
|
|
42
|
+
` } : { subject: `Reset your password \u2014 ${product}`, text: `Hello,
|
|
43
|
+
|
|
44
|
+
To choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):
|
|
45
|
+
${url}
|
|
46
|
+
|
|
47
|
+
If you did not ask for this, ignore this message: your password does not change.
|
|
48
|
+
` };
|
|
49
|
+
}
|
|
50
|
+
function emailChangeMail(product, locale, url) {
|
|
51
|
+
return locale === "fr" ? { subject: `Confirmez votre nouvelle adresse \u2014 ${product}`, text: `Bonjour,
|
|
52
|
+
|
|
53
|
+
Pour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :
|
|
54
|
+
${url}
|
|
55
|
+
|
|
56
|
+
Si vous n'avez rien demand\xE9, ignorez ce message : rien ne change.
|
|
57
|
+
` } : { subject: `Confirm your new address \u2014 ${product}`, text: `Hello,
|
|
58
|
+
|
|
59
|
+
To use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):
|
|
60
|
+
${url}
|
|
61
|
+
|
|
62
|
+
If you did not ask for this, ignore this message: nothing changes.
|
|
63
|
+
` };
|
|
64
|
+
}
|
|
65
|
+
var looksLikeEmail = (address) => {
|
|
66
|
+
if (address.length > 320 || /\s/.test(address)) return false;
|
|
67
|
+
const at = address.indexOf("@");
|
|
68
|
+
if (at < 1 || at !== address.lastIndexOf("@")) return false;
|
|
69
|
+
const domain = address.slice(at + 1);
|
|
70
|
+
const dot = domain.lastIndexOf(".");
|
|
71
|
+
return dot > 0 && dot < domain.length - 1;
|
|
72
|
+
};
|
|
73
|
+
var cleanName = (raw) => {
|
|
74
|
+
const name = raw.replace(/\s+/g, " ").trim();
|
|
75
|
+
if (name.length > NAME_MAX) throw new AccountError(400, "BAD_REQUEST", `A name is at most ${NAME_MAX} characters`);
|
|
76
|
+
return name;
|
|
77
|
+
};
|
|
78
|
+
function dbAccounts(deps) {
|
|
79
|
+
const { query } = deps;
|
|
80
|
+
const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);
|
|
81
|
+
const transaction = deps.transaction ?? ((work) => work(query));
|
|
82
|
+
const wrong = () => new AccountError(403, "WRONG_PASSWORD", "The password is not right");
|
|
83
|
+
async function profileOf(id) {
|
|
84
|
+
const [row] = await query(
|
|
85
|
+
`SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,
|
|
86
|
+
(SELECT c.new_email FROM email_changes c WHERE c.account_id = a.id AND c.used_at IS NULL AND c.expires_at > now() ORDER BY c.created_at DESC LIMIT 1) AS pending
|
|
87
|
+
FROM accounts a WHERE a.id = $1`,
|
|
88
|
+
[id]
|
|
89
|
+
);
|
|
90
|
+
if (!row) throw new AccountError(404, "NOT_FOUND", "No such account");
|
|
91
|
+
return { id: row.id, email: row.email, name: row.name, locale: row.locale, theme: row.theme, avatarSeed: row.avatar_seed, emailVerified: !!row.email_verified_at, pendingEmail: row.pending };
|
|
92
|
+
}
|
|
93
|
+
async function confirmPassword(id, password) {
|
|
94
|
+
const [row] = await query("SELECT password_hash FROM accounts WHERE id = $1", [id]);
|
|
95
|
+
if (!row?.password_hash || password.length > PASSWORD_MAX || !await deps.passwords.verify(password, row.password_hash)) throw wrong();
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
profile: profileOf,
|
|
99
|
+
async updateProfile(id, patch) {
|
|
100
|
+
const name = patch.name === void 0 ? void 0 : patch.name === null ? null : cleanName(patch.name);
|
|
101
|
+
if (patch.avatarSeed && !/^[a-z0-9-]{1,40}$/.test(patch.avatarSeed)) throw new AccountError(400, "BAD_REQUEST", "Not an avatar");
|
|
102
|
+
if (patch.theme && !THEMES.includes(patch.theme)) throw new AccountError(400, "BAD_REQUEST", "Not a theme");
|
|
103
|
+
await query(
|
|
104
|
+
`UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),
|
|
105
|
+
avatar_seed = CASE WHEN $5::boolean THEN $6 ELSE avatar_seed END, theme = CASE WHEN $7::boolean THEN $8 ELSE theme END WHERE id = $1`,
|
|
106
|
+
[id, name !== void 0, name === "" ? null : name ?? null, patch.locale ?? null, patch.avatarSeed !== void 0, patch.avatarSeed ?? null, patch.theme !== void 0, patch.theme ?? null]
|
|
107
|
+
);
|
|
108
|
+
return profileOf(id);
|
|
109
|
+
},
|
|
110
|
+
async requestEmailChange(id, input, now) {
|
|
111
|
+
await confirmPassword(id, input.password);
|
|
112
|
+
const address = input.newEmail.trim().toLowerCase();
|
|
113
|
+
if (!looksLikeEmail(address)) throw new AccountError(400, "BAD_REQUEST", "Not an e-mail address");
|
|
114
|
+
const [taken] = await query("SELECT 1 FROM accounts WHERE email = $1", [address]);
|
|
115
|
+
if (taken) return;
|
|
116
|
+
const profile = await profileOf(id);
|
|
117
|
+
await query("UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [id, now]);
|
|
118
|
+
const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();
|
|
119
|
+
await query("INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);
|
|
120
|
+
const url = `${deps.baseUrl.replace(/\/$/, "")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;
|
|
121
|
+
await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url), link: { kind: "emailChange", url, hours: EMAIL_CHANGE_TTL_HOURS } });
|
|
122
|
+
},
|
|
123
|
+
async confirmEmailChange(token, now) {
|
|
124
|
+
if (!isEmailChangeToken(deps.tokenPrefix, token)) return "INVALID";
|
|
125
|
+
const [row] = await query("UPDATE email_changes SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id, new_email", [deps.tokenHash(token), now]);
|
|
126
|
+
if (!row) return "INVALID";
|
|
127
|
+
try {
|
|
128
|
+
await query("UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1", [row.account_id, row.new_email, now]);
|
|
129
|
+
} catch {
|
|
130
|
+
return "INVALID";
|
|
131
|
+
}
|
|
132
|
+
return "CHANGED";
|
|
133
|
+
},
|
|
134
|
+
async changePassword(id, input, keepSessionId) {
|
|
135
|
+
await confirmPassword(id, input.current);
|
|
136
|
+
if (input.next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
|
|
137
|
+
if (input.next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
|
|
138
|
+
await query("UPDATE accounts SET password_hash = $2 WHERE id = $1", [id, await deps.passwords.hash(input.next)]);
|
|
139
|
+
await query("DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)", [id, keepSessionId ?? null]);
|
|
140
|
+
},
|
|
141
|
+
confirmPassword,
|
|
142
|
+
async requestPasswordReset(email, now) {
|
|
143
|
+
const address = email.trim().toLowerCase();
|
|
144
|
+
if (!looksLikeEmail(address)) return;
|
|
145
|
+
const issued = await transaction(async (q) => {
|
|
146
|
+
const [row] = await q("SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE", [address]);
|
|
147
|
+
if (!row) return null;
|
|
148
|
+
await q("UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL", [row.id, now]);
|
|
149
|
+
const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();
|
|
150
|
+
await q("INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);
|
|
151
|
+
return { token, locale: row.locale };
|
|
152
|
+
});
|
|
153
|
+
if (!issued) return;
|
|
154
|
+
const url = `${deps.baseUrl.replace(/\/$/, "")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;
|
|
155
|
+
await deps.mail({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes), link: { kind: "passwordReset", url, minutes: resetTtlMinutes } });
|
|
156
|
+
},
|
|
157
|
+
async resetPassword(token, next, now) {
|
|
158
|
+
if (next.length < PASSWORD_MIN) throw new AccountError(400, "PASSWORD_TOO_SHORT", `At least ${PASSWORD_MIN} characters`);
|
|
159
|
+
if (next.length > PASSWORD_MAX) throw new AccountError(400, "PASSWORD_TOO_LONG", `At most ${PASSWORD_MAX} characters`);
|
|
160
|
+
if (!isLinkToken(deps.tokenPrefix, token)) return "INVALID";
|
|
161
|
+
return transaction(async (q) => {
|
|
162
|
+
const [row] = await q("UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id", [deps.tokenHash(token), now]);
|
|
163
|
+
if (!row) return "INVALID";
|
|
164
|
+
await q("UPDATE accounts SET password_hash = $2 WHERE id = $1", [row.account_id, await deps.passwords.hash(next)]);
|
|
165
|
+
await q("DELETE FROM sessions WHERE account_id = $1", [row.account_id]);
|
|
166
|
+
return "CHANGED";
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
async sessions(id, now, currentSessionId) {
|
|
170
|
+
const rows = await query("SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC", [id, now]);
|
|
171
|
+
return rows.map((r) => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));
|
|
172
|
+
},
|
|
173
|
+
async signOutOthers(id, keepSessionId) {
|
|
174
|
+
return (await query("DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id", [id, keepSessionId])).length;
|
|
175
|
+
},
|
|
176
|
+
async exportAccount(id, now, extra) {
|
|
177
|
+
const profile = await profileOf(id);
|
|
178
|
+
const live = await this.sessions(id, now);
|
|
179
|
+
return { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...extra ? await extra() : {} };
|
|
180
|
+
},
|
|
181
|
+
async deleteAccount(id) {
|
|
182
|
+
const rows = await query("DELETE FROM accounts WHERE id = $1 RETURNING id", [id]);
|
|
183
|
+
if (!rows.length) throw new AccountError(404, "NOT_FOUND", "No such account");
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
var postgresAccounts = dbAccounts;
|
|
188
|
+
|
|
189
|
+
export {
|
|
190
|
+
AccountError,
|
|
191
|
+
EMAIL_CHANGE_TTL_HOURS,
|
|
192
|
+
PASSWORD_RESET_TTL_MINUTES,
|
|
193
|
+
PASSWORD_RESET_MIN_MINUTES,
|
|
194
|
+
PASSWORD_RESET_MAX_MINUTES,
|
|
195
|
+
linkToken,
|
|
196
|
+
isLinkToken,
|
|
197
|
+
emailChangeToken,
|
|
198
|
+
isEmailChangeToken,
|
|
199
|
+
passwordResetMail,
|
|
200
|
+
emailChangeMail,
|
|
201
|
+
dbAccounts,
|
|
202
|
+
postgresAccounts
|
|
203
|
+
};
|
|
204
|
+
//# sourceMappingURL=chunk-3CLSDLXB.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/port.ts","../src/accounts.ts"],"sourcesContent":["/**\n * The account PORT (ADR-0008 of Yomizaka, now shared). A product's account — name, language, theme, avatar, e-mail change with\n * verification, password (changed when signed in, or reset by a mailed link), sessions, export, deletion — behind one interface. Every product keeps its OWN accounts: no central\n * service, no runtime dependency between products.\n */\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\nexport interface AccountProfile {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n /** The theme the person chose; null = follow the device. */\n theme: Theme | null;\n /** A seed of the default avatars (the UI draws it); null = derived from the e-mail address. */\n avatarSeed: string | null;\n emailVerified: boolean;\n /** An address awaiting its verification link, if the person asked to change it. */\n pendingEmail: string | null;\n}\n\nexport interface ProfilePatch {\n name?: string | null;\n locale?: \"en\" | \"fr\";\n theme?: Theme | null;\n avatarSeed?: string | null;\n}\n\n/** A sign-in of the person: where from and when. Never the token. */\nexport interface SessionSummary {\n id: string;\n createdAt: Date;\n expiresAt: Date;\n ip: string | null;\n userAgent: string | null;\n current: boolean;\n}\n\nexport interface AccountService {\n profile(accountId: string): Promise<AccountProfile>;\n updateProfile(accountId: string, patch: ProfilePatch): Promise<AccountProfile>;\n /** Asks to change the address: the password is checked, a link is mailed to the NEW address. Answers the same whether or not it is already taken (no enumeration). */\n requestEmailChange(accountId: string, input: { newEmail: string; password: string }, now: Date): Promise<void>;\n /** Applies the change from the mailed link (single use, expires); the new address is verified by that very act. */\n confirmEmailChange(token: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Changes the password (the current one is checked) and signs every other session out; `keepSessionId` stays. */\n changePassword(accountId: string, input: { current: string; next: string }, keepSessionId?: string): Promise<void>;\n /** Asks for a reset link, mailed (in the person's language) when an account with a password has this address; answers the same either way (no enumeration). */\n requestPasswordReset(email: string, now: Date): Promise<void>;\n /** Sets a new password from the mailed link — single use, expires — and signs every session out. The new password is checked before the link is used up. */\n resetPassword(token: string, next: string, now: Date): Promise<\"CHANGED\" | \"INVALID\">;\n /** Checks the password. Throws `AccountError(403, \"WRONG_PASSWORD\")`. */\n confirmPassword(accountId: string, password: string): Promise<void>;\n /** The person's live sign-ins, newest first; `currentSessionId` is flagged. */\n sessions(accountId: string, now: Date, currentSessionId?: string): Promise<SessionSummary[]>;\n /** Signs every other session out (and returns how many); `keepSessionId` stays. */\n signOutOthers(accountId: string, keepSessionId: string): Promise<number>;\n /** What the product keeps about the person, as one JSON-able document: the profile, the live sign-ins, and what `extra` adds. */\n exportAccount(accountId: string, now: Date, extra?: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>>;\n /** Deletes the identity: the account, its sessions and everything that hangs on it (the product erases its own data first). */\n deleteAccount(accountId: string): Promise<void>;\n}\n\nexport class AccountError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n ) {\n super(message);\n this.name = \"AccountError\";\n }\n}\n","import crypto from \"node:crypto\";\n\nimport type { Query } from \"@krizaka/db\";\nimport { addHours, addMinutes } from \"date-fns\";\n\nimport { AccountError, type AccountProfile, type AccountService, type ProfilePatch, type SessionSummary, type Theme } from \"./port\";\n\n/** A SQL executor: `@krizaka/db`'s `Query`, which the product passes (its pool, or its transaction's client). */\nexport type { Query };\n\nexport interface Mail {\n to: string;\n /** The language of the mail (the person's). */\n locale: \"en\" | \"fr\";\n subject: string;\n text: string;\n /**\n * The link this mail carries, as data: a product that sends branded HTML (`@krizaka/email`: `passwordReset`, `emailChange`)\n * renders it from this instead of from `text`. Absent on a mail that is not a link.\n */\n link?: MailLink;\n}\n\n/** A mailed link: what it is for, where it goes, and how long it lives. */\nexport interface MailLink {\n kind: \"passwordReset\" | \"emailChange\";\n url: string;\n /** `passwordReset`: the lifetime in minutes. */\n minutes?: number;\n /** `emailChange`: the lifetime in hours. */\n hours?: number;\n}\n\nexport interface AccountDeps {\n query: Query;\n /**\n * Runs `work` in one transaction on one connection (BEGIN … COMMIT, rolled back when `work` throws) — the product's pool\n * knows how. The password reset runs under it: the account row is locked, the links retired and issued, the token claimed,\n * the password set and the sessions ended as one. Without it, the statements run one by one on `query` (one process, no\n * concurrent requests): give it in production.\n */\n transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;\n mail: (mail: Mail) => Promise<void>;\n /** `https://www.akinaza.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */\n baseUrl: string;\n /** The product's name, in the words of the mail (« Akinaza »). */\n product: string;\n /** The product's token prefix, two or three letters (`ye1.` for Yomizaka: `ye`). The tokens are `<prefix>1.<43 base64url>`. */\n tokenPrefix: string;\n passwords: { hash: (password: string) => Promise<string>; verify: (password: string, stored: string) => Promise<boolean> };\n /** How a token is kept: the product's own keyed hash, the one its sessions use. */\n tokenHash: (token: string) => string;\n /**\n * How long a password reset link lives, in minutes: a whole number from 5 to 1440 (`PASSWORD_RESET_MIN_MINUTES`–`PASSWORD_RESET_MAX_MINUTES`),\n * default `PASSWORD_RESET_TTL_MINUTES` (30). The product reads it from its settings; the expiry and the mailed text both use it.\n * Out of range, `dbAccounts` throws.\n */\n passwordResetTtlMinutes?: number;\n /** Test seam: the token to issue. */\n token?: () => string;\n}\n\n// @fixed: the lifetime of an e-mail change link, a security parameter\nexport const EMAIL_CHANGE_TTL_HOURS = 24;\n/** The default lifetime of a password reset link, in minutes (`AccountDeps.passwordResetTtlMinutes` changes it). */\n// @fixed: the default lifetime of a password reset link, a security parameter\nexport const PASSWORD_RESET_TTL_MINUTES = 30;\n// @fixed: the shortest lifetime a product may set: a person must have time to open the mail\nexport const PASSWORD_RESET_MIN_MINUTES = 5;\n// @fixed: the longest lifetime a product may set: a day\nexport const PASSWORD_RESET_MAX_MINUTES = 1440;\n\nconst checkResetTtl = (minutes: number): number => {\n if (!Number.isInteger(minutes) || minutes < PASSWORD_RESET_MIN_MINUTES || minutes > PASSWORD_RESET_MAX_MINUTES) {\n throw new RangeError(`passwordResetTtlMinutes must be a whole number from ${PASSWORD_RESET_MIN_MINUTES} to ${PASSWORD_RESET_MAX_MINUTES}, got ${minutes}`);\n }\n return minutes;\n};\nconst NAME_MAX = 80;\nconst PASSWORD_MIN = 12;\nconst PASSWORD_MAX = 256;\nconst THEMES: readonly Theme[] = [\"light\", \"dark\", \"system\"];\n\n/** One format for every mailed link (an e-mail change, a password reset): the product's prefix, a version, 256 random bits. */\nexport const linkToken = (prefix: string) => `${prefix}1.${crypto.randomBytes(32).toString(\"base64url\")}`;\nexport const isLinkToken = (prefix: string, value: string | undefined | null): value is string => !!value && new RegExp(`^${prefix}1\\\\.[A-Za-z0-9_-]{43}$`).test(value);\nexport const emailChangeToken = linkToken;\nexport const isEmailChangeToken = isLinkToken;\n\nexport function passwordResetMail(product: string, locale: \"en\" | \"fr\", url: string, ttlMinutes: number = PASSWORD_RESET_TTL_MINUTES) {\n checkResetTtl(ttlMinutes);\n return locale === \"fr\"\n ? { subject: `Réinitialisez votre mot de passe — ${product}`, text: `Bonjour,\\n\\nPour choisir un nouveau mot de passe pour votre compte ${product}, ouvrez ce lien (valide ${ttlMinutes} min, une seule fois) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : votre mot de passe ne change pas.\\n` }\n : { subject: `Reset your password — ${product}`, text: `Hello,\\n\\nTo choose a new password for your ${product} account, open this link (valid ${ttlMinutes} min, once):\\n${url}\\n\\nIf you did not ask for this, ignore this message: your password does not change.\\n` };\n}\n\nexport function emailChangeMail(product: string, locale: \"en\" | \"fr\", url: string) {\n return locale === \"fr\"\n ? { subject: `Confirmez votre nouvelle adresse — ${product}`, text: `Bonjour,\\n\\nPour utiliser cette adresse avec votre compte ${product}, confirmez-la en ouvrant ce lien (valide ${EMAIL_CHANGE_TTL_HOURS} h) :\\n${url}\\n\\nSi vous n'avez rien demandé, ignorez ce message : rien ne change.\\n` }\n : { subject: `Confirm your new address — ${product}`, text: `Hello,\\n\\nTo use this address with your ${product} account, confirm it by opening this link (valid ${EMAIL_CHANGE_TTL_HOURS} h):\\n${url}\\n\\nIf you did not ask for this, ignore this message: nothing changes.\\n` };\n}\n\n/** One « @ », something on each side, a dot inside the domain, no space — checked without a backtracking pattern. */\nconst looksLikeEmail = (address: string): boolean => {\n if (address.length > 320 || /\\s/.test(address)) return false;\n const at = address.indexOf(\"@\");\n if (at < 1 || at !== address.lastIndexOf(\"@\")) return false;\n const domain = address.slice(at + 1);\n const dot = domain.lastIndexOf(\".\");\n return dot > 0 && dot < domain.length - 1;\n};\n\nconst cleanName = (raw: string): string => {\n const name = raw.replace(/\\s+/g, \" \").trim();\n if (name.length > NAME_MAX) throw new AccountError(400, \"BAD_REQUEST\", `A name is at most ${NAME_MAX} characters`);\n return name;\n};\n\ninterface ProfileRow {\n id: string;\n email: string;\n name: string | null;\n locale: \"en\" | \"fr\";\n theme: Theme | null;\n avatar_seed: string | null;\n email_verified_at: Date | null;\n pending: string | null;\n}\n\n/** The adapter over the product's own PostgreSQL (`accounts`, `sessions`, `email_changes`, `password_resets`; see `accountsMigration`). */\nexport function dbAccounts(deps: AccountDeps): AccountService {\n const { query } = deps;\n const resetTtlMinutes = checkResetTtl(deps.passwordResetTtlMinutes ?? PASSWORD_RESET_TTL_MINUTES);\n const transaction = deps.transaction ?? (<T>(work: (q: Query) => Promise<T>) => work(query));\n const wrong = () => new AccountError(403, \"WRONG_PASSWORD\", \"The password is not right\");\n\n async function profileOf(id: string): Promise<AccountProfile> {\n const [row] = await query<ProfileRow>(\n `SELECT a.id, a.email, a.name, a.locale, a.theme, a.avatar_seed, a.email_verified_at,\n (SELECT c.new_email FROM email_changes c WHERE c.account_id = a.id AND c.used_at IS NULL AND c.expires_at > now() ORDER BY c.created_at DESC LIMIT 1) AS pending\n FROM accounts a WHERE a.id = $1`,\n [id],\n );\n if (!row) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n return { id: row.id, email: row.email, name: row.name, locale: row.locale, theme: row.theme, avatarSeed: row.avatar_seed, emailVerified: !!row.email_verified_at, pendingEmail: row.pending };\n }\n\n async function confirmPassword(id: string, password: string): Promise<void> {\n const [row] = await query<{ password_hash: string | null }>(\"SELECT password_hash FROM accounts WHERE id = $1\", [id]);\n if (!row?.password_hash || password.length > PASSWORD_MAX || !(await deps.passwords.verify(password, row.password_hash))) throw wrong();\n }\n\n return {\n profile: profileOf,\n\n async updateProfile(id, patch: ProfilePatch) {\n const name = patch.name === undefined ? undefined : patch.name === null ? null : cleanName(patch.name);\n if (patch.avatarSeed && !/^[a-z0-9-]{1,40}$/.test(patch.avatarSeed)) throw new AccountError(400, \"BAD_REQUEST\", \"Not an avatar\");\n if (patch.theme && !THEMES.includes(patch.theme)) throw new AccountError(400, \"BAD_REQUEST\", \"Not a theme\");\n await query(\n `UPDATE accounts SET name = CASE WHEN $2::boolean THEN $3 ELSE name END, locale = COALESCE($4, locale),\n avatar_seed = CASE WHEN $5::boolean THEN $6 ELSE avatar_seed END, theme = CASE WHEN $7::boolean THEN $8 ELSE theme END WHERE id = $1`,\n [id, name !== undefined, name === \"\" ? null : (name ?? null), patch.locale ?? null, patch.avatarSeed !== undefined, patch.avatarSeed ?? null, patch.theme !== undefined, patch.theme ?? null],\n );\n return profileOf(id);\n },\n\n async requestEmailChange(id, input, now) {\n await confirmPassword(id, input.password);\n const address = input.newEmail.trim().toLowerCase();\n if (!looksLikeEmail(address)) throw new AccountError(400, \"BAD_REQUEST\", \"Not an e-mail address\");\n const [taken] = await query(\"SELECT 1 FROM accounts WHERE email = $1\", [address]);\n if (taken) return; // the same answer, no mail: the address is not disclosed\n const profile = await profileOf(id);\n await query(\"UPDATE email_changes SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [id, now]);\n const token = (deps.token ?? (() => emailChangeToken(deps.tokenPrefix)))();\n await query(\"INSERT INTO email_changes (account_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)\", [id, address, deps.tokenHash(token), addHours(now, EMAIL_CHANGE_TTL_HOURS)]);\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${profile.locale}/confirm-email?token=${encodeURIComponent(token)}`;\n await deps.mail({ to: address, locale: profile.locale, ...emailChangeMail(deps.product, profile.locale, url), link: { kind: \"emailChange\", url, hours: EMAIL_CHANGE_TTL_HOURS } });\n },\n\n async confirmEmailChange(token, now) {\n if (!isEmailChangeToken(deps.tokenPrefix, token)) return \"INVALID\";\n const [row] = await query<{ account_id: string; new_email: string }>(\"UPDATE email_changes SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id, new_email\", [deps.tokenHash(token), now]);\n if (!row) return \"INVALID\";\n try {\n await query(\"UPDATE accounts SET email = $2, email_verified_at = $3 WHERE id = $1\", [row.account_id, row.new_email, now]);\n } catch {\n return \"INVALID\"; // taken in the meantime\n }\n return \"CHANGED\";\n },\n\n async changePassword(id, input, keepSessionId) {\n await confirmPassword(id, input.current);\n if (input.next.length < PASSWORD_MIN) throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (input.next.length > PASSWORD_MAX) throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n await query(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [id, await deps.passwords.hash(input.next)]);\n await query(\"DELETE FROM sessions WHERE account_id = $1 AND ($2::uuid IS NULL OR id <> $2::uuid)\", [id, keepSessionId ?? null]);\n },\n\n confirmPassword,\n\n async requestPasswordReset(email, now) {\n const address = email.trim().toLowerCase();\n if (!looksLikeEmail(address)) return; // the same answer: nothing is disclosed\n // One transaction, the account row locked: two requests at once leave one valid link — the later one. The mail goes after the commit.\n const issued = await transaction(async (q) => {\n const [row] = await q<{ id: string; locale: \"en\" | \"fr\" }>(\"SELECT id, locale FROM accounts WHERE email = $1 AND password_hash IS NOT NULL FOR UPDATE\", [address]);\n if (!row) return null;\n await q(\"UPDATE password_resets SET used_at = $2 WHERE account_id = $1 AND used_at IS NULL\", [row.id, now]);\n const token = (deps.token ?? (() => linkToken(deps.tokenPrefix)))();\n await q(\"INSERT INTO password_resets (account_id, token_hash, expires_at) VALUES ($1, $2, $3)\", [row.id, deps.tokenHash(token), addMinutes(now, resetTtlMinutes)]);\n return { token, locale: row.locale };\n });\n if (!issued) return;\n const url = `${deps.baseUrl.replace(/\\/$/, \"\")}/${issued.locale}/reset-password?token=${encodeURIComponent(issued.token)}`;\n await deps.mail({ to: address, locale: issued.locale, ...passwordResetMail(deps.product, issued.locale, url, resetTtlMinutes), link: { kind: \"passwordReset\", url, minutes: resetTtlMinutes } });\n },\n\n async resetPassword(token, next, now) {\n if (next.length < PASSWORD_MIN) throw new AccountError(400, \"PASSWORD_TOO_SHORT\", `At least ${PASSWORD_MIN} characters`);\n if (next.length > PASSWORD_MAX) throw new AccountError(400, \"PASSWORD_TOO_LONG\", `At most ${PASSWORD_MAX} characters`);\n if (!isLinkToken(deps.tokenPrefix, token)) return \"INVALID\";\n // One transaction: the link is claimed, the password set and every session ended as one — a failure leaves the link usable.\n return transaction(async (q) => {\n const [row] = await q<{ account_id: string }>(\"UPDATE password_resets SET used_at = $2 WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2 RETURNING account_id\", [deps.tokenHash(token), now]);\n if (!row) return \"INVALID\" as const;\n await q(\"UPDATE accounts SET password_hash = $2 WHERE id = $1\", [row.account_id, await deps.passwords.hash(next)]);\n await q(\"DELETE FROM sessions WHERE account_id = $1\", [row.account_id]);\n return \"CHANGED\" as const;\n });\n },\n\n async sessions(id, now, currentSessionId) {\n const rows = await query<{ id: string; created_at: Date; expires_at: Date; ip: string | null; user_agent: string | null }>(\"SELECT id, created_at, expires_at, ip, user_agent FROM sessions WHERE account_id = $1 AND expires_at > $2 ORDER BY created_at DESC\", [id, now]);\n return rows.map((r): SessionSummary => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, ip: r.ip, userAgent: r.user_agent, current: r.id === currentSessionId }));\n },\n\n async signOutOthers(id, keepSessionId) {\n return (await query(\"DELETE FROM sessions WHERE account_id = $1 AND id <> $2::uuid RETURNING id\", [id, keepSessionId])).length;\n },\n\n async exportAccount(id, now, extra) {\n const profile = await profileOf(id);\n const live = await this.sessions(id, now);\n return { exportedAt: now.toISOString(), account: profile, sessions: live.map(({ current: _current, ...s }) => s), ...(extra ? await extra() : {}) };\n },\n\n async deleteAccount(id) {\n const rows = await query(\"DELETE FROM accounts WHERE id = $1 RETURNING id\", [id]);\n if (!rows.length) throw new AccountError(404, \"NOT_FOUND\", \"No such account\");\n },\n };\n}\n\n/** @deprecated The old name of `dbAccounts` (a name says what a thing does, not the technology). Removed in the next major. */\nexport const postgresAccounts = dbAccounts;\n"],"mappings":"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","names":[]}
|
|
@@ -149,6 +149,29 @@ CREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(ac
|
|
|
149
149
|
}
|
|
150
150
|
]
|
|
151
151
|
});
|
|
152
|
+
var sessionsSchema = defineModule({
|
|
153
|
+
module: "auth_sessions",
|
|
154
|
+
schema: "public",
|
|
155
|
+
tables: ["accounts"],
|
|
156
|
+
migrations: [
|
|
157
|
+
{
|
|
158
|
+
version: 1,
|
|
159
|
+
name: "sessions",
|
|
160
|
+
sql: (c) => `
|
|
161
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
162
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
163
|
+
account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,
|
|
164
|
+
token_hash text NOT NULL UNIQUE,
|
|
165
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
166
|
+
expires_at timestamptz NOT NULL,
|
|
167
|
+
ip text,
|
|
168
|
+
user_agent text
|
|
169
|
+
);
|
|
170
|
+
CREATE INDEX IF NOT EXISTS sessions_account ON sessions(account_id);
|
|
171
|
+
`
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
});
|
|
152
175
|
|
|
153
176
|
// src/identity-links.ts
|
|
154
177
|
import { compareAsc } from "date-fns";
|
|
@@ -223,9 +246,10 @@ export {
|
|
|
223
246
|
accountsSchema,
|
|
224
247
|
rolesSchema,
|
|
225
248
|
identityLinksSchema,
|
|
249
|
+
sessionsSchema,
|
|
226
250
|
IdentityLinkError,
|
|
227
251
|
memoryIdentityLinks,
|
|
228
252
|
identityLinksMigration,
|
|
229
253
|
dbIdentityLinks
|
|
230
254
|
};
|
|
231
|
-
//# sourceMappingURL=chunk-
|
|
255
|
+
//# sourceMappingURL=chunk-5CFOMWBN.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/federation.ts","../src/schema.ts","../src/identity-links.ts"],"sourcesContent":["import crypto from \"node:crypto\";\n\n/**\n * Federated sign-in (Google, Apple, Facebook, a native app's PKCE) delegated to krizaka-users, behind a PORT. The product keeps its\n * own accounts (`postgresAccounts`); this port only answers « who is this person, according to the provider ». An external\n * identity is never a second user table: it is a link to an account the product already has.\n */\nexport type FederatedProvider = \"google\" | \"apple\" | \"facebook\";\n\n/** The providers the port knows; anything else is refused before it reaches the adapter. */\nexport const FEDERATED_PROVIDERS: readonly FederatedProvider[] = [\"google\", \"apple\", \"facebook\"];\n\nexport const isFederatedProvider = (value: unknown): value is FederatedProvider => FEDERATED_PROVIDERS.includes(value as FederatedProvider);\n\n/** Who the provider says the person is. `issuer` + `subject` identify them for good; the address can change. */\nexport interface ExternalIdentity {\n /** The authority that vouches (`https://accounts.google.com`, `https://appleid.apple.com`…). */\n issuer: string;\n /** The person's stable id at that authority. */\n subject: string;\n email: string | null;\n /** The provider checked the address (a Facebook address, for one, may not be). */\n emailVerified: boolean;\n name: string | null;\n}\n\n/** What a sign-in is started with: a native app sends the PKCE challenge, a browser flow does not. */\nexport interface StartOptions {\n /** The S256 challenge of a PKCE flow (a native app); the matching verifier goes to `complete`. */\n codeChallenge?: string;\n}\n\nexport interface StartedSignIn {\n /** Where to send the person: the provider's consent page, through krizaka-users. */\n url: string;\n /** Echoed back with the code: the product checks it against the one it kept for this browser. */\n state: string;\n}\n\nexport interface CompleteOptions {\n /** The PKCE verifier of the challenge given to `start`. */\n codeVerifier?: string;\n}\n\nexport interface IdentityProvider {\n /** Starts a sign-in with `provider`; the person comes back to `returnTo` (a path or an address the adapter allows) with a one-time code. */\n start(provider: FederatedProvider, returnTo: string, options?: StartOptions): Promise<StartedSignIn>;\n /** Exchanges the one-time code (single use) for the verified identity. Throws `FederationError(\"INVALID_CODE\")` for a code that is unknown, used or expired. */\n complete(code: string, options?: CompleteOptions): Promise<ExternalIdentity>;\n}\n\nexport const FEDERATION_CODE = {\n invalidCode: \"INVALID_CODE\",\n unknownProvider: \"UNKNOWN_PROVIDER\",\n badReturn: \"BAD_RETURN_TO\",\n unavailable: \"UNAVAILABLE\",\n} as const;\nexport type FederationCode = (typeof FEDERATION_CODE)[keyof typeof FEDERATION_CODE];\n\nexport class FederationError extends Error {\n constructor(\n readonly code: FederationCode,\n message: string,\n ) {\n super(message);\n this.name = \"FederationError\";\n }\n}\n\n/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). A native app computes it; the adapter and the in-memory provider check it. */\nexport const pkceChallenge = (verifier: string): string => crypto.createHash(\"sha256\").update(verifier).digest(\"base64url\");\n\n/** A return address stays on the product: a path (`/fr/account`), never `//host` or a scheme. */\nexport const isLocalReturn = (returnTo: string): boolean => returnTo.startsWith(\"/\") && !returnTo.startsWith(\"//\") && !returnTo.includes(\"\\\\\") && [...returnTo].every((c) => c.charCodeAt(0) > 31);\n\n/** An in-memory provider: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityProvider(): IdentityProvider & {\n /** Test seam: the one-time code the provider would hand back for this identity (with the PKCE challenge it was started with, if any). */\n issue(identity: ExternalIdentity, challenge?: string): string;\n} {\n const pending = new Map<string, { identity: ExternalIdentity; challenge: string | null }>();\n let counter = 0;\n return {\n async start(provider, returnTo) {\n if (!isFederatedProvider(provider)) throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);\n if (!isLocalReturn(returnTo)) throw new FederationError(FEDERATION_CODE.badReturn, \"That return address is not allowed\");\n counter += 1;\n const state = `state-${counter}`;\n return { url: `https://auth.test/${provider}/start?state=${state}`, state };\n },\n async complete(code, options = {}) {\n const entry = pending.get(code);\n pending.delete(code); // single use, whatever happens next\n if (!entry) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n if (entry.challenge !== null && (!options.codeVerifier || pkceChallenge(options.codeVerifier) !== entry.challenge)) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n return entry.identity;\n },\n issue(identity, challenge) {\n counter += 1;\n pending.set(`code-${counter}`, { identity, challenge: challenge ?? null });\n return `code-${counter}`;\n },\n };\n}\n","import { defineModule, type ModuleSchema } from \"@krizaka/db\";\n\n/**\n * The tables of « auth », declared as modules (ADR-001 §3). They are added to a product's own `accounts` and `sessions` tables, which\n * existed before the module schemas: they stay in `public`, un-qualified, and a product that has them adopts version 1\n * (`adoptMigration`, markers only) while one that does not emits it (`emitMigration`). Version 1 of each is frozen once released:\n * a change is version 2. The one product table every one of them references is declared by the product (`accounts`, `owner`).\n */\n\n/**\n * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:\n * a display name, a theme, an avatar seed, the verified-address flag, and the table of e-mail changes (a link mailed to the NEW\n * address) and the table of password resets (a link mailed to the account's address). `accounts` needs `id`, `email`, `locale`, `password_hash`;\n * `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.\n * @param options `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products)\n */\nexport function accountsSchema(options: { backfillVerified?: boolean } = {}): ModuleSchema {\n return defineModule({\n module: \"auth\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"accounts profile, e-mail changes, password resets\",\n sql: (c) => `\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;\n${options.backfillVerified ? `UPDATE ${c.tables.accounts} SET email_verified_at = created_at WHERE email_verified_at IS NULL;\\n` : \"\"}CREATE TABLE IF NOT EXISTS email_changes (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n new_email text NOT NULL CHECK (new_email = lower(new_email)),\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE TABLE IF NOT EXISTS password_resets (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\n`,\n },\n ],\n });\n}\n\nconst IDENT = /^[a-z_][a-z0-9_]{0,62}$/;\nconst ident = (name: string) => {\n if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);\n return name;\n};\n\n/**\n * Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the\n * product's role model. Declares the account table (`owner`) and, when the roles are scoped, the scope table (`scope`: shops, workspaces):\n * a membership and an invitation then point at it. Idempotent.\n * @param roles the role names of the model: each becomes a value of the `role` check, so each must be a safe identifier\n * @param options `scoped`: true when the product declares a `scope` table\n * @throws Error for a role that is not a safe SQL identifier\n */\nexport function rolesSchema(roles: readonly string[], options: { scoped?: boolean } = {}): ModuleSchema {\n const list = roles.map((r) => `'${ident(r)}'`).join(\", \");\n return defineModule({\n module: \"auth_roles\",\n schema: \"public\",\n tables: options.scoped ? [\"owner\", \"scope\"] : [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"memberships and invitations\",\n sql: (c) => {\n const scope = options.scoped ? ` REFERENCES ${c.tables.scope}(id) ON DELETE CASCADE` : \"\";\n return `\nCREATE TABLE IF NOT EXISTS memberships (\n scope_id uuid NOT NULL${scope},\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN (${list})),\n invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n PRIMARY KEY (scope_id, account_id)\n);\nCREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);\nCREATE TABLE IF NOT EXISTS invitations (\n id uuid PRIMARY KEY,\n scope_id uuid NOT NULL${scope},\n email text NOT NULL CHECK (email = lower(email)),\n role text NOT NULL CHECK (role IN (${list})),\n token_hash text NOT NULL UNIQUE,\n invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;\n`;\n },\n },\n ],\n });\n}\n\n/** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */\nexport const identityLinksSchema = defineModule({\n module: \"auth_identity_links\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"external identities\",\n sql: (c) => `\nCREATE TABLE IF NOT EXISTS external_identities (\n issuer text NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 200),\n subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 200),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n linked_at timestamptz NOT NULL,\n PRIMARY KEY (issuer, subject)\n);\nCREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(account_id, linked_at);\n`,\n },\n ],\n});\n","import { compareAsc } from \"date-fns\";\n\nimport type { Query } from \"./accounts\";\nimport { identityLinksSchema } from \"./schema\";\n\n/**\n * Which external identity belongs to which account. Never a second user table: a row is only `(issuer, subject) → account`.\n * The product's `accounts` stays the one place a person lives.\n */\nexport interface IdentityLinkStore {\n /** The account this external identity is linked to, or null. */\n find(issuer: string, subject: string): Promise<string | null>;\n /** Links it. Idempotent for the same account; throws when the identity is already linked to another one. */\n link(accountId: string, issuer: string, subject: string, now: Date): Promise<void>;\n /** Removes the link (the person unlinks Google); true when there was one. */\n unlink(accountId: string, issuer: string, subject: string): Promise<boolean>;\n /** The identities linked to an account, oldest first. */\n list(accountId: string): Promise<{ issuer: string; subject: string; linkedAt: Date }[]>;\n}\n\nexport class IdentityLinkError extends Error {\n readonly code = \"ALREADY_LINKED\";\n constructor() {\n super(\"That external identity is already linked to another account\");\n this.name = \"IdentityLinkError\";\n }\n}\n\n/** An in-memory store: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityLinks(): IdentityLinkStore {\n const rows = new Map<string, { accountId: string; issuer: string; subject: string; linkedAt: Date }>();\n const key = (issuer: string, subject: string) => `${issuer}\\n${subject}`;\n return {\n async find(issuer, subject) {\n return rows.get(key(issuer, subject))?.accountId ?? null;\n },\n async link(accountId, issuer, subject, now) {\n const existing = rows.get(key(issuer, subject));\n if (existing && existing.accountId !== accountId) throw new IdentityLinkError();\n if (!existing) rows.set(key(issuer, subject), { accountId, issuer, subject, linkedAt: now });\n },\n async unlink(accountId, issuer, subject) {\n const existing = rows.get(key(issuer, subject));\n if (existing?.accountId !== accountId) return false;\n rows.delete(key(issuer, subject));\n return true;\n },\n async list(accountId) {\n return [...rows.values()].filter((r) => r.accountId === accountId).sort((a, b) => compareAsc(a.linkedAt, b.linkedAt)).map(({ issuer, subject, linkedAt }) => ({ issuer, subject, linkedAt }));\n },\n };\n}\n\n/**\n * The table of links (idempotent). `accounts` is the product's account table.\n * @deprecated Use `identityLinksSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.\n */\nexport function identityLinksMigration(): string {\n return identityLinksSchema.migrations[0]!.sql({ schema: \"public\", tables: { accounts: \"accounts\" } });\n}\n\n/** The store over the product's own database (`external_identities`; see `identityLinksMigration`). */\nexport function dbIdentityLinks(query: Query): IdentityLinkStore {\n return {\n async find(issuer, subject) {\n const [row] = await query<{ account_id: string }>(\"SELECT account_id FROM external_identities WHERE issuer = $1 AND subject = $2\", [issuer, subject]);\n return row?.account_id ?? null;\n },\n async link(accountId, issuer, subject, now) {\n // One statement: the primary key decides a race; the same account is a no-op, another account is refused.\n const rows = await query<{ account_id: string }>(\n `INSERT INTO external_identities (issuer, subject, account_id, linked_at) VALUES ($1, $2, $3, $4)\n ON CONFLICT (issuer, subject) DO UPDATE SET issuer = excluded.issuer WHERE external_identities.account_id = excluded.account_id\n RETURNING account_id`,\n [issuer, subject, accountId, now],\n );\n if (rows.length === 0) throw new IdentityLinkError();\n },\n async unlink(accountId, issuer, subject) {\n const rows = await query(\"DELETE FROM external_identities WHERE issuer = $1 AND subject = $2 AND account_id = $3 RETURNING 1\", [issuer, subject, accountId]);\n return rows.length > 0;\n },\n async list(accountId) {\n const rows = await query<{ issuer: string; subject: string; linked_at: Date }>(\"SELECT issuer, subject, linked_at FROM external_identities WHERE account_id = $1 ORDER BY linked_at, issuer, subject\", [accountId]);\n return rows.map((r) => ({ issuer: r.issuer, subject: r.subject, linkedAt: r.linked_at }));\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/federation.ts","../src/schema.ts","../src/identity-links.ts"],"sourcesContent":["import crypto from \"node:crypto\";\n\n/**\n * Federated sign-in (Google, Apple, Facebook, a native app's PKCE) delegated to krizaka-users, behind a PORT. The product keeps its\n * own accounts (`postgresAccounts`); this port only answers « who is this person, according to the provider ». An external\n * identity is never a second user table: it is a link to an account the product already has.\n */\nexport type FederatedProvider = \"google\" | \"apple\" | \"facebook\";\n\n/** The providers the port knows; anything else is refused before it reaches the adapter. */\nexport const FEDERATED_PROVIDERS: readonly FederatedProvider[] = [\"google\", \"apple\", \"facebook\"];\n\nexport const isFederatedProvider = (value: unknown): value is FederatedProvider => FEDERATED_PROVIDERS.includes(value as FederatedProvider);\n\n/** Who the provider says the person is. `issuer` + `subject` identify them for good; the address can change. */\nexport interface ExternalIdentity {\n /** The authority that vouches (`https://accounts.google.com`, `https://appleid.apple.com`…). */\n issuer: string;\n /** The person's stable id at that authority. */\n subject: string;\n email: string | null;\n /** The provider checked the address (a Facebook address, for one, may not be). */\n emailVerified: boolean;\n name: string | null;\n}\n\n/** What a sign-in is started with: a native app sends the PKCE challenge, a browser flow does not. */\nexport interface StartOptions {\n /** The S256 challenge of a PKCE flow (a native app); the matching verifier goes to `complete`. */\n codeChallenge?: string;\n}\n\nexport interface StartedSignIn {\n /** Where to send the person: the provider's consent page, through krizaka-users. */\n url: string;\n /** Echoed back with the code: the product checks it against the one it kept for this browser. */\n state: string;\n}\n\nexport interface CompleteOptions {\n /** The PKCE verifier of the challenge given to `start`. */\n codeVerifier?: string;\n}\n\nexport interface IdentityProvider {\n /** Starts a sign-in with `provider`; the person comes back to `returnTo` (a path or an address the adapter allows) with a one-time code. */\n start(provider: FederatedProvider, returnTo: string, options?: StartOptions): Promise<StartedSignIn>;\n /** Exchanges the one-time code (single use) for the verified identity. Throws `FederationError(\"INVALID_CODE\")` for a code that is unknown, used or expired. */\n complete(code: string, options?: CompleteOptions): Promise<ExternalIdentity>;\n}\n\nexport const FEDERATION_CODE = {\n invalidCode: \"INVALID_CODE\",\n unknownProvider: \"UNKNOWN_PROVIDER\",\n badReturn: \"BAD_RETURN_TO\",\n unavailable: \"UNAVAILABLE\",\n} as const;\nexport type FederationCode = (typeof FEDERATION_CODE)[keyof typeof FEDERATION_CODE];\n\nexport class FederationError extends Error {\n constructor(\n readonly code: FederationCode,\n message: string,\n ) {\n super(message);\n this.name = \"FederationError\";\n }\n}\n\n/** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). A native app computes it; the adapter and the in-memory provider check it. */\nexport const pkceChallenge = (verifier: string): string => crypto.createHash(\"sha256\").update(verifier).digest(\"base64url\");\n\n/** A return address stays on the product: a path (`/fr/account`), never `//host` or a scheme. */\nexport const isLocalReturn = (returnTo: string): boolean => returnTo.startsWith(\"/\") && !returnTo.startsWith(\"//\") && !returnTo.includes(\"\\\\\") && [...returnTo].every((c) => c.charCodeAt(0) > 31);\n\n/** An in-memory provider: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityProvider(): IdentityProvider & {\n /** Test seam: the one-time code the provider would hand back for this identity (with the PKCE challenge it was started with, if any). */\n issue(identity: ExternalIdentity, challenge?: string): string;\n} {\n const pending = new Map<string, { identity: ExternalIdentity; challenge: string | null }>();\n let counter = 0;\n return {\n async start(provider, returnTo) {\n if (!isFederatedProvider(provider)) throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);\n if (!isLocalReturn(returnTo)) throw new FederationError(FEDERATION_CODE.badReturn, \"That return address is not allowed\");\n counter += 1;\n const state = `state-${counter}`;\n return { url: `https://auth.test/${provider}/start?state=${state}`, state };\n },\n async complete(code, options = {}) {\n const entry = pending.get(code);\n pending.delete(code); // single use, whatever happens next\n if (!entry) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n if (entry.challenge !== null && (!options.codeVerifier || pkceChallenge(options.codeVerifier) !== entry.challenge)) throw new FederationError(FEDERATION_CODE.invalidCode, \"That code is not valid\");\n return entry.identity;\n },\n issue(identity, challenge) {\n counter += 1;\n pending.set(`code-${counter}`, { identity, challenge: challenge ?? null });\n return `code-${counter}`;\n },\n };\n}\n","import { defineModule, type ModuleSchema } from \"@krizaka/db\";\n\n/**\n * The tables of « auth », declared as modules (ADR-001 §3). They are added to a product's own `accounts` and `sessions` tables, which\n * existed before the module schemas: they stay in `public`, un-qualified, and a product that has them adopts version 1\n * (`adoptMigration`, markers only) while one that does not emits it (`emitMigration`). Version 1 of each is frozen once released:\n * a change is version 2. The one product table every one of them references is declared by the product (`accounts`, `owner`).\n */\n\n/**\n * What the account adapter needs from a product's database, safe to run on a database that already has `accounts` and `sessions`:\n * a display name, a theme, an avatar seed, the verified-address flag, and the table of e-mail changes (a link mailed to the NEW\n * address) and the table of password resets (a link mailed to the account's address). `accounts` needs `id`, `email`, `locale`, `password_hash`;\n * `sessions` needs `account_id`, `created_at`, `expires_at`, `ip`, `user_agent`.\n * @param options `backfillVerified`: for accounts that exist already, treat the address as verified (invitation-only products)\n */\nexport function accountsSchema(options: { backfillVerified?: boolean } = {}): ModuleSchema {\n return defineModule({\n module: \"auth\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"accounts profile, e-mail changes, password resets\",\n sql: (c) => `\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS name text CHECK (name IS NULL OR char_length(name) <= 80);\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS avatar_seed text CHECK (avatar_seed IS NULL OR avatar_seed ~ '^[a-z0-9-]{1,40}$');\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS theme text CHECK (theme IS NULL OR theme IN ('light', 'dark', 'system'));\nALTER TABLE ${c.tables.accounts} ADD COLUMN IF NOT EXISTS email_verified_at timestamptz;\n${options.backfillVerified ? `UPDATE ${c.tables.accounts} SET email_verified_at = created_at WHERE email_verified_at IS NULL;\\n` : \"\"}CREATE TABLE IF NOT EXISTS email_changes (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n new_email text NOT NULL CHECK (new_email = lower(new_email)),\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE TABLE IF NOT EXISTS password_resets (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\n`,\n },\n ],\n });\n}\n\nconst IDENT = /^[a-z_][a-z0-9_]{0,62}$/;\nconst ident = (name: string) => {\n if (!IDENT.test(name)) throw new Error(`not a safe SQL identifier: ${name}`);\n return name;\n};\n\n/**\n * Memberships (who has which role in which scope) and invitations (an e-mail address invited with a role, by a link used once), from the\n * product's role model. Declares the account table (`owner`) and, when the roles are scoped, the scope table (`scope`: shops, workspaces):\n * a membership and an invitation then point at it. Idempotent.\n * @param roles the role names of the model: each becomes a value of the `role` check, so each must be a safe identifier\n * @param options `scoped`: true when the product declares a `scope` table\n * @throws Error for a role that is not a safe SQL identifier\n */\nexport function rolesSchema(roles: readonly string[], options: { scoped?: boolean } = {}): ModuleSchema {\n const list = roles.map((r) => `'${ident(r)}'`).join(\", \");\n return defineModule({\n module: \"auth_roles\",\n schema: \"public\",\n tables: options.scoped ? [\"owner\", \"scope\"] : [\"owner\"],\n migrations: [\n {\n version: 1,\n name: \"memberships and invitations\",\n sql: (c) => {\n const scope = options.scoped ? ` REFERENCES ${c.tables.scope}(id) ON DELETE CASCADE` : \"\";\n return `\nCREATE TABLE IF NOT EXISTS memberships (\n scope_id uuid NOT NULL${scope},\n account_id uuid NOT NULL REFERENCES ${c.tables.owner}(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN (${list})),\n invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n PRIMARY KEY (scope_id, account_id)\n);\nCREATE INDEX IF NOT EXISTS memberships_account ON memberships(account_id);\nCREATE TABLE IF NOT EXISTS invitations (\n id uuid PRIMARY KEY,\n scope_id uuid NOT NULL${scope},\n email text NOT NULL CHECK (email = lower(email)),\n role text NOT NULL CHECK (role IN (${list})),\n token_hash text NOT NULL UNIQUE,\n invited_by uuid REFERENCES ${c.tables.owner}(id) ON DELETE SET NULL,\n created_at timestamptz NOT NULL,\n expires_at timestamptz NOT NULL,\n used_at timestamptz\n);\nCREATE INDEX IF NOT EXISTS invitations_scope ON invitations(scope_id) WHERE used_at IS NULL;\n`;\n },\n },\n ],\n });\n}\n\n/** Which external identity belongs to which account (federated sign-in): `(issuer, subject) → account`, never a second user table. Idempotent. */\nexport const identityLinksSchema = defineModule({\n module: \"auth_identity_links\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"external identities\",\n sql: (c) => `\nCREATE TABLE IF NOT EXISTS external_identities (\n issuer text NOT NULL CHECK (char_length(issuer) BETWEEN 1 AND 200),\n subject text NOT NULL CHECK (char_length(subject) BETWEEN 1 AND 200),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n linked_at timestamptz NOT NULL,\n PRIMARY KEY (issuer, subject)\n);\nCREATE INDEX IF NOT EXISTS external_identities_account ON external_identities(account_id, linked_at);\n`,\n },\n ],\n});\n\n/**\n * The table of web sessions: the cookie holds a random token, the table its keyed hash (a leaked table opens nothing). The shape the\n * products' own `sessions` table has, so a product that has it adopts version 1 (`adoptMigration`) and one that does not emits it.\n * Idempotent.\n */\nexport const sessionsSchema = defineModule({\n module: \"auth_sessions\",\n schema: \"public\",\n tables: [\"accounts\"],\n migrations: [\n {\n version: 1,\n name: \"sessions\",\n sql: (c) => `\nCREATE TABLE IF NOT EXISTS sessions (\n id uuid PRIMARY KEY DEFAULT gen_random_uuid(),\n account_id uuid NOT NULL REFERENCES ${c.tables.accounts}(id) ON DELETE CASCADE,\n token_hash text NOT NULL UNIQUE,\n created_at timestamptz NOT NULL DEFAULT now(),\n expires_at timestamptz NOT NULL,\n ip text,\n user_agent text\n);\nCREATE INDEX IF NOT EXISTS sessions_account ON sessions(account_id);\n`,\n },\n ],\n});\n","import { compareAsc } from \"date-fns\";\n\nimport type { Query } from \"./accounts\";\nimport { identityLinksSchema } from \"./schema\";\n\n/**\n * Which external identity belongs to which account. Never a second user table: a row is only `(issuer, subject) → account`.\n * The product's `accounts` stays the one place a person lives.\n */\nexport interface IdentityLinkStore {\n /** The account this external identity is linked to, or null. */\n find(issuer: string, subject: string): Promise<string | null>;\n /** Links it. Idempotent for the same account; throws when the identity is already linked to another one. */\n link(accountId: string, issuer: string, subject: string, now: Date): Promise<void>;\n /** Removes the link (the person unlinks Google); true when there was one. */\n unlink(accountId: string, issuer: string, subject: string): Promise<boolean>;\n /** The identities linked to an account, oldest first. */\n list(accountId: string): Promise<{ issuer: string; subject: string; linkedAt: Date }[]>;\n}\n\nexport class IdentityLinkError extends Error {\n readonly code = \"ALREADY_LINKED\";\n constructor() {\n super(\"That external identity is already linked to another account\");\n this.name = \"IdentityLinkError\";\n }\n}\n\n/** An in-memory store: the tests' adapter, and the reference the contract is written against. */\nexport function memoryIdentityLinks(): IdentityLinkStore {\n const rows = new Map<string, { accountId: string; issuer: string; subject: string; linkedAt: Date }>();\n const key = (issuer: string, subject: string) => `${issuer}\\n${subject}`;\n return {\n async find(issuer, subject) {\n return rows.get(key(issuer, subject))?.accountId ?? null;\n },\n async link(accountId, issuer, subject, now) {\n const existing = rows.get(key(issuer, subject));\n if (existing && existing.accountId !== accountId) throw new IdentityLinkError();\n if (!existing) rows.set(key(issuer, subject), { accountId, issuer, subject, linkedAt: now });\n },\n async unlink(accountId, issuer, subject) {\n const existing = rows.get(key(issuer, subject));\n if (existing?.accountId !== accountId) return false;\n rows.delete(key(issuer, subject));\n return true;\n },\n async list(accountId) {\n return [...rows.values()].filter((r) => r.accountId === accountId).sort((a, b) => compareAsc(a.linkedAt, b.linkedAt)).map(({ issuer, subject, linkedAt }) => ({ issuer, subject, linkedAt }));\n },\n };\n}\n\n/**\n * The table of links (idempotent). `accounts` is the product's account table.\n * @deprecated Use `identityLinksSchema` with `emitMigration` or `adoptMigration` from `@krizaka/db`. Removed in the next major.\n */\nexport function identityLinksMigration(): string {\n return identityLinksSchema.migrations[0]!.sql({ schema: \"public\", tables: { accounts: \"accounts\" } });\n}\n\n/** The store over the product's own database (`external_identities`; see `identityLinksMigration`). */\nexport function dbIdentityLinks(query: Query): IdentityLinkStore {\n return {\n async find(issuer, subject) {\n const [row] = await query<{ account_id: string }>(\"SELECT account_id FROM external_identities WHERE issuer = $1 AND subject = $2\", [issuer, subject]);\n return row?.account_id ?? null;\n },\n async link(accountId, issuer, subject, now) {\n // One statement: the primary key decides a race; the same account is a no-op, another account is refused.\n const rows = await query<{ account_id: string }>(\n `INSERT INTO external_identities (issuer, subject, account_id, linked_at) VALUES ($1, $2, $3, $4)\n ON CONFLICT (issuer, subject) DO UPDATE SET issuer = excluded.issuer WHERE external_identities.account_id = excluded.account_id\n RETURNING account_id`,\n [issuer, subject, accountId, now],\n );\n if (rows.length === 0) throw new IdentityLinkError();\n },\n async unlink(accountId, issuer, subject) {\n const rows = await query(\"DELETE FROM external_identities WHERE issuer = $1 AND subject = $2 AND account_id = $3 RETURNING 1\", [issuer, subject, accountId]);\n return rows.length > 0;\n },\n async list(accountId) {\n const rows = await query<{ issuer: string; subject: string; linked_at: Date }>(\"SELECT issuer, subject, linked_at FROM external_identities WHERE account_id = $1 ORDER BY linked_at, issuer, subject\", [accountId]);\n return rows.map((r) => ({ issuer: r.issuer, subject: r.subject, linkedAt: r.linked_at }));\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* « Who is here »: the one contract of `GET /api/me` for every product (krizaka-ui#551). Browser-safe: zod and the web-standard
|
|
5
|
+
* `Response` only, so the product's route and the bar island of `@krizaka/ui` import the same file.
|
|
6
|
+
*
|
|
7
|
+
* A public page asking who is there is not an error: signed out is 200 with `signedIn: false`, never a 401. The answer is a hint for the
|
|
8
|
+
* bar; it authorises nothing, and every page and route checks the session itself.
|
|
9
|
+
*/
|
|
10
|
+
/** The path every product serves. */
|
|
11
|
+
declare const ME_PATH = "/api/me";
|
|
12
|
+
/** What the bar shows of the signed-in person. */
|
|
13
|
+
declare const meAccountSchema: z.ZodObject<{
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
name: z.ZodNullable<z.ZodString>;
|
|
16
|
+
email: z.ZodString;
|
|
17
|
+
avatarSeed: z.ZodNullable<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
declare const meSchema: z.ZodObject<{
|
|
20
|
+
signedIn: z.ZodBoolean;
|
|
21
|
+
signupOpen: z.ZodBoolean;
|
|
22
|
+
account: z.ZodOptional<z.ZodObject<{
|
|
23
|
+
id: z.ZodString;
|
|
24
|
+
name: z.ZodNullable<z.ZodString>;
|
|
25
|
+
email: z.ZodString;
|
|
26
|
+
avatarSeed: z.ZodNullable<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>>;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
type MeAccount = z.infer<typeof meAccountSchema>;
|
|
30
|
+
type Me = z.infer<typeof meSchema>;
|
|
31
|
+
/** Never cached, never shared: the answer depends on the cookie. */
|
|
32
|
+
declare const ME_HEADERS: {
|
|
33
|
+
readonly "Cache-Control": "private, no-store";
|
|
34
|
+
readonly Vary: "Cookie";
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The answer of `GET /api/me`: 200, the body checked against the contract, `Cache-Control: private, no-store` and `Vary: Cookie`.
|
|
38
|
+
* @param input whether sign-up is open, and the signed-in account when there is one
|
|
39
|
+
* @throws ZodError when the body breaks the contract (a signed-in answer without an account)
|
|
40
|
+
*/
|
|
41
|
+
declare function meResponse(input: {
|
|
42
|
+
signupOpen: boolean;
|
|
43
|
+
account?: MeAccount | null | undefined;
|
|
44
|
+
}): Response;
|
|
45
|
+
|
|
46
|
+
export { ME_HEADERS, ME_PATH, type Me, type MeAccount, meAccountSchema, meResponse, meSchema };
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// src/contract.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var ME_PATH = "/api/me";
|
|
4
|
+
var meAccountSchema = z.object({
|
|
5
|
+
id: z.string().min(1),
|
|
6
|
+
name: z.string().nullable(),
|
|
7
|
+
email: z.string().min(1),
|
|
8
|
+
avatarSeed: z.string().nullable()
|
|
9
|
+
});
|
|
10
|
+
var meSchema = z.object({
|
|
11
|
+
signedIn: z.boolean(),
|
|
12
|
+
signupOpen: z.boolean(),
|
|
13
|
+
account: meAccountSchema.optional()
|
|
14
|
+
}).refine((me) => me.signedIn === (me.account !== void 0), { message: "account is present exactly when signedIn" });
|
|
15
|
+
var ME_HEADERS = { "Cache-Control": "private, no-store", Vary: "Cookie" };
|
|
16
|
+
function meResponse(input) {
|
|
17
|
+
const body = meSchema.parse({ signedIn: !!input.account, signupOpen: input.signupOpen, ...input.account ? { account: input.account } : {} });
|
|
18
|
+
return Response.json(body, { headers: ME_HEADERS });
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
ME_HEADERS,
|
|
22
|
+
ME_PATH,
|
|
23
|
+
meAccountSchema,
|
|
24
|
+
meResponse,
|
|
25
|
+
meSchema
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/contract.ts"],"sourcesContent":["import { z } from \"zod\";\n\n/**\n * « Who is here »: the one contract of `GET /api/me` for every product (krizaka-ui#551). Browser-safe: zod and the web-standard\n * `Response` only, so the product's route and the bar island of `@krizaka/ui` import the same file.\n *\n * A public page asking who is there is not an error: signed out is 200 with `signedIn: false`, never a 401. The answer is a hint for the\n * bar; it authorises nothing, and every page and route checks the session itself.\n */\n\n/** The path every product serves. */\nexport const ME_PATH = \"/api/me\";\n\n/** What the bar shows of the signed-in person. */\nexport const meAccountSchema = z.object({\n id: z.string().min(1),\n name: z.string().nullable(),\n email: z.string().min(1),\n avatarSeed: z.string().nullable(),\n});\n\nexport const meSchema = z\n .object({\n signedIn: z.boolean(),\n signupOpen: z.boolean(),\n account: meAccountSchema.optional(),\n })\n .refine((me) => me.signedIn === (me.account !== undefined), { message: \"account is present exactly when signedIn\" });\n\nexport type MeAccount = z.infer<typeof meAccountSchema>;\nexport type Me = z.infer<typeof meSchema>;\n\n/** Never cached, never shared: the answer depends on the cookie. */\nexport const ME_HEADERS = { \"Cache-Control\": \"private, no-store\", Vary: \"Cookie\" } as const;\n\n/**\n * The answer of `GET /api/me`: 200, the body checked against the contract, `Cache-Control: private, no-store` and `Vary: Cookie`.\n * @param input whether sign-up is open, and the signed-in account when there is one\n * @throws ZodError when the body breaks the contract (a signed-in answer without an account)\n */\nexport function meResponse(input: { signupOpen: boolean; account?: MeAccount | null | undefined }): Response {\n const body: Me = meSchema.parse({ signedIn: !!input.account, signupOpen: input.signupOpen, ...(input.account ? { account: input.account } : {}) });\n return Response.json(body, { headers: ME_HEADERS });\n}\n"],"mappings":";AAAA,SAAS,SAAS;AAWX,IAAM,UAAU;AAGhB,IAAM,kBAAkB,EAAE,OAAO;AAAA,EACtC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACpB,MAAM,EAAE,OAAO,EAAE,SAAS;AAAA,EAC1B,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EACvB,YAAY,EAAE,OAAO,EAAE,SAAS;AAClC,CAAC;AAEM,IAAM,WAAW,EACrB,OAAO;AAAA,EACN,UAAU,EAAE,QAAQ;AAAA,EACpB,YAAY,EAAE,QAAQ;AAAA,EACtB,SAAS,gBAAgB,SAAS;AACpC,CAAC,EACA,OAAO,CAAC,OAAO,GAAG,cAAc,GAAG,YAAY,SAAY,EAAE,SAAS,2CAA2C,CAAC;AAM9G,IAAM,aAAa,EAAE,iBAAiB,qBAAqB,MAAM,SAAS;AAO1E,SAAS,WAAW,OAAkF;AAC3G,QAAM,OAAW,SAAS,MAAM,EAAE,UAAU,CAAC,CAAC,MAAM,SAAS,YAAY,MAAM,YAAY,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC,EAAG,CAAC;AACjJ,SAAO,SAAS,KAAK,MAAM,EAAE,SAAS,WAAW,CAAC;AACpD;","names":[]}
|