@krizaka/auth 0.13.1 → 0.13.3
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 +53 -12
- package/dist/{chunk-5CFOMWBN.js → chunk-2QUHBEBL.js} +19 -7
- package/dist/chunk-2QUHBEBL.js.map +1 -0
- package/dist/{chunk-G46LWJPQ.js → chunk-3MIBOYLS.js} +6 -2
- package/dist/{chunk-G46LWJPQ.js.map → chunk-3MIBOYLS.js.map} +1 -1
- package/dist/{chunk-3CLSDLXB.js → chunk-73MO6V5M.js} +123 -30
- package/dist/chunk-73MO6V5M.js.map +1 -0
- package/dist/{chunk-2PWX2B4I.js → chunk-RWKBQE6N.js} +10 -2
- package/dist/chunk-RWKBQE6N.js.map +1 -0
- package/dist/client.js +2 -2
- package/dist/contract.js +1 -1
- package/dist/index.d.ts +11 -10
- package/dist/index.js +124 -32
- package/dist/index.js.map +1 -1
- package/dist/{sessions-DZXjHjzd.d.ts → sessions-DscdTKEJ.d.ts} +2 -2
- package/dist/settings.js +6 -2
- package/dist/settings.js.map +1 -1
- package/dist/testkit.d.ts +1 -1
- package/dist/testkit.js +36 -8
- package/dist/testkit.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-2PWX2B4I.js.map +0 -1
- package/dist/chunk-3CLSDLXB.js.map +0 -1
- package/dist/chunk-5CFOMWBN.js.map +0 -1
package/dist/contract.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Query, ModuleSchema } from '@krizaka/db';
|
|
2
2
|
export { Query } from '@krizaka/db';
|
|
3
|
-
import { E as ExternalIdentity, a as IdentityLinkStore, S as SessionStore, N as NewSession, I as IdentityProvider } from './sessions-
|
|
4
|
-
export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-
|
|
3
|
+
import { E as ExternalIdentity, a as IdentityLinkStore, S as SessionStore, N as NewSession, I as IdentityProvider } from './sessions-DscdTKEJ.js';
|
|
4
|
+
export { C as CompleteOptions, F as FEDERATED_PROVIDERS, b as FEDERATION_CODE, c as FederatedProvider, d as FederationCode, e as FederationError, f as IdentityLinkError, g as Session, h as SessionCookie, i as Sessions, j as SessionsDeps, k as StartOptions, l as StartedSignIn, m as clearedSessionCookie, n as createSessions, o as dbIdentityLinks, p as identityLinksMigration, q as isFederatedProvider, r as isLocalReturn, s as memoryIdentityLinks, t as memoryIdentityProvider, u as pkceChallenge, v as sessionCookie, w as sessionTokenOf } from './sessions-DscdTKEJ.js';
|
|
5
5
|
import { a as RoleModel } from './roles-model-CSCe8O57.js';
|
|
6
6
|
export { R as ROLES_CODE, b as RolesCode, c as RolesError, d as can, e as defineRoles } from './roles-model-CSCe8O57.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* The account PORT (ADR-0008 of
|
|
9
|
+
* The account PORT (ADR-0008 of Verikora, now shared). A product's account — name, language, theme, avatar, e-mail change with
|
|
10
10
|
* 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
|
|
11
11
|
* service, no runtime dependency between products.
|
|
12
12
|
*/
|
|
@@ -106,11 +106,11 @@ interface AccountDeps {
|
|
|
106
106
|
*/
|
|
107
107
|
transaction?: <T>(work: (query: Query) => Promise<T>) => Promise<T>;
|
|
108
108
|
mail: (mail: Mail) => Promise<void>;
|
|
109
|
-
/** `https://www.
|
|
109
|
+
/** `https://www.folioviv.com`: the e-mail change link is `<baseUrl>/<locale>/confirm-email?token=…`, the reset link `<baseUrl>/<locale>/reset-password?token=…`. */
|
|
110
110
|
baseUrl: string;
|
|
111
|
-
/** The product's name, in the words of the mail («
|
|
111
|
+
/** The product's name, in the words of the mail (« Folioviv »). */
|
|
112
112
|
product: string;
|
|
113
|
-
/** The product's token prefix, two or three letters (`ye1.` for
|
|
113
|
+
/** The product's token prefix, two or three letters (`ye1.` for Verikora: `ye`). The tokens are `<prefix>1.<43 base64url>`. */
|
|
114
114
|
tokenPrefix: string;
|
|
115
115
|
passwords: {
|
|
116
116
|
hash: (password: string) => Promise<string>;
|
|
@@ -279,14 +279,14 @@ type FederatedSignIn = ReturnType<typeof createFederatedSignIn>;
|
|
|
279
279
|
declare const SESSION_HINT_COOKIE = "kz-session";
|
|
280
280
|
/** Where a signed-in visitor of the landing route goes. */
|
|
281
281
|
interface LandingRule {
|
|
282
|
-
/** The product's session cookie name (`
|
|
282
|
+
/** The product's session cookie name (`verikora_session`). */
|
|
283
283
|
cookie: string;
|
|
284
284
|
/** The landing routes (`["/", "/fr", "/en"]`): exact paths, a trailing slash tolerated. */
|
|
285
285
|
landing: readonly string[];
|
|
286
286
|
/** The signed-in start page, or a function of the request (the language of the address). */
|
|
287
287
|
home: string | ((request: Request) => string);
|
|
288
288
|
/**
|
|
289
|
-
* The product's own public origin (`https://dev.
|
|
289
|
+
* The product's own public origin (`https://dev.verikora.com`), from its declared environment (APP_URL), NEVER from the
|
|
290
290
|
* request: `Host` and `x-forwarded-*` are client-controlled. With it the Location is absolute on that origin (what a
|
|
291
291
|
* Next.js proxy accepts). Only the origin of the value is used. Optional outside production (the Location is then
|
|
292
292
|
* relative); REQUIRED in production, where a missing origin throws instead of falling back to the request's headers.
|
|
@@ -494,7 +494,7 @@ type Fetcher = (url: string, init: {
|
|
|
494
494
|
json(): Promise<unknown>;
|
|
495
495
|
}>;
|
|
496
496
|
interface UsersFederationOptions {
|
|
497
|
-
/** The tenant host of the product: `https://auth.dev.
|
|
497
|
+
/** The tenant host of the product: `https://auth.dev.folioviv.com`. */
|
|
498
498
|
baseUrl: string;
|
|
499
499
|
/** The product's public address: the start URL sends the person back to it, so `returnTo` is absolute from it. */
|
|
500
500
|
appUrl: string;
|
|
@@ -503,7 +503,8 @@ interface UsersFederationOptions {
|
|
|
503
503
|
/** Reads krizaka-users' answer into an identity; anything that is not a complete identity is refused. */
|
|
504
504
|
declare function parseExternalIdentity(body: unknown): ExternalIdentity;
|
|
505
505
|
/**
|
|
506
|
-
* The adapter for
|
|
506
|
+
* The adapter for the users service (`auth.<product>` tenant host), which lives in krizaka/krizaka-java `krizaka-users/` since
|
|
507
|
+
* ADR-021 (the federation landed as krizaka/krizaka-users#31, the old split): `start` builds the tenant's start address,
|
|
507
508
|
* `complete` exchanges the one-time code (and the PKCE verifier of a native app) for the verified identity.
|
|
508
509
|
*/
|
|
509
510
|
declare function usersIdentityProvider({ baseUrl, appUrl, fetch }: UsersFederationOptions): IdentityProvider;
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
pkceChallenge,
|
|
15
15
|
rolesSchema,
|
|
16
16
|
sessionsSchema
|
|
17
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-2QUHBEBL.js";
|
|
18
18
|
import {
|
|
19
19
|
AccountError,
|
|
20
20
|
EMAIL_CHANGE_TTL_HOURS,
|
|
@@ -29,13 +29,13 @@ import {
|
|
|
29
29
|
linkToken,
|
|
30
30
|
passwordResetMail,
|
|
31
31
|
postgresAccounts
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-73MO6V5M.js";
|
|
33
33
|
import {
|
|
34
34
|
ROLES_CODE,
|
|
35
35
|
RolesError,
|
|
36
36
|
can,
|
|
37
37
|
defineRoles
|
|
38
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-RWKBQE6N.js";
|
|
39
39
|
|
|
40
40
|
// src/admin-gate.ts
|
|
41
41
|
var ADMIN_REFUSAL = {
|
|
@@ -136,14 +136,18 @@ function landingRedirect(request, rule) {
|
|
|
136
136
|
const path = `${target.pathname}${target.search}${target.hash}`;
|
|
137
137
|
const base = rule.origin ? new URL(rule.origin).origin : null;
|
|
138
138
|
const location = base && new URL(home, base).origin === base ? new URL(home, base).href : path;
|
|
139
|
-
return new Response(null, {
|
|
139
|
+
return new Response(null, {
|
|
140
|
+
status: 307,
|
|
141
|
+
headers: { Location: location, "Cache-Control": "private, no-store", Vary: "Cookie" }
|
|
142
|
+
});
|
|
140
143
|
}
|
|
141
144
|
function sessionHint(response, request, options) {
|
|
142
145
|
const session = Boolean(cookieValue(request.headers, options.cookie));
|
|
143
146
|
const flagged = cookieValue(request.headers, SESSION_HINT_COOKIE) === "1";
|
|
144
147
|
const base = `${SESSION_HINT_COOKIE}=${session ? "1" : ""}; Path=/; SameSite=Lax${options.secure ? "; Secure" : ""}`;
|
|
145
148
|
if (session && !flagged) response.headers.append("Set-Cookie", base);
|
|
146
|
-
else if (!session && cookieValue(request.headers, SESSION_HINT_COOKIE) !== void 0)
|
|
149
|
+
else if (!session && cookieValue(request.headers, SESSION_HINT_COOKIE) !== void 0)
|
|
150
|
+
response.headers.append("Set-Cookie", `${base}; Max-Age=0`);
|
|
147
151
|
return response;
|
|
148
152
|
}
|
|
149
153
|
|
|
@@ -157,8 +161,14 @@ function accountsMigration(options = {}) {
|
|
|
157
161
|
return accountsSchema(options).migrations[0].sql({ schema: "public", tables: { accounts: "accounts" } });
|
|
158
162
|
}
|
|
159
163
|
function rolesMigration(roles, options = {}) {
|
|
160
|
-
const tables = {
|
|
161
|
-
|
|
164
|
+
const tables = {
|
|
165
|
+
owner: ident(options.ownerTable ?? "accounts"),
|
|
166
|
+
...options.scopeTable ? { scope: ident(options.scopeTable) } : {}
|
|
167
|
+
};
|
|
168
|
+
return rolesSchema(roles, { scoped: options.scopeTable !== void 0 }).migrations[0].sql({
|
|
169
|
+
schema: "public",
|
|
170
|
+
tables
|
|
171
|
+
});
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
// src/password.ts
|
|
@@ -188,7 +198,12 @@ async function verifyPassword(password, stored) {
|
|
|
188
198
|
const [scheme, n, r, p, salt, hash] = stored.split("$");
|
|
189
199
|
if (scheme !== "scrypt" || !salt || !hash || password.length > PASSWORD_MAX_LENGTH) return false;
|
|
190
200
|
const expected = Buffer.from(hash, "base64url");
|
|
191
|
-
const given = await scrypt(password, Buffer.from(salt, "base64url"), expected.length, {
|
|
201
|
+
const given = await scrypt(password, Buffer.from(salt, "base64url"), expected.length, {
|
|
202
|
+
N: Number(n),
|
|
203
|
+
r: Number(r),
|
|
204
|
+
p: Number(p),
|
|
205
|
+
maxmem: MAXMEM
|
|
206
|
+
});
|
|
192
207
|
return given.length === expected.length && crypto2.timingSafeEqual(given, expected);
|
|
193
208
|
}
|
|
194
209
|
var decoy;
|
|
@@ -205,10 +220,16 @@ function createRoles(deps) {
|
|
|
205
220
|
return role;
|
|
206
221
|
};
|
|
207
222
|
const newToken = deps.token ?? (() => `${deps.tokenPrefix}1.${crypto3.randomBytes(32).toString("base64url")}`);
|
|
208
|
-
const owners = (scopeId) => query("SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2", [
|
|
223
|
+
const owners = (scopeId) => query("SELECT account_id FROM memberships WHERE scope_id = $1 AND role = $2", [
|
|
224
|
+
scopeId,
|
|
225
|
+
ownerRole()
|
|
226
|
+
]);
|
|
209
227
|
const ownerRole = () => model.roles.find((r) => model.roles.every((x) => model.manages[r].includes(x) || x === r)) ?? model.roles[0];
|
|
210
228
|
async function roleOf(accountId, scopeId) {
|
|
211
|
-
const [row] = await query("SELECT role FROM memberships WHERE scope_id = $1 AND account_id = $2", [
|
|
229
|
+
const [row] = await query("SELECT role FROM memberships WHERE scope_id = $1 AND account_id = $2", [
|
|
230
|
+
scopeId,
|
|
231
|
+
accountId
|
|
232
|
+
]);
|
|
212
233
|
return row?.role ?? null;
|
|
213
234
|
}
|
|
214
235
|
return {
|
|
@@ -216,7 +237,10 @@ function createRoles(deps) {
|
|
|
216
237
|
roleOf,
|
|
217
238
|
/** Every scope the person belongs to, with their role in it. */
|
|
218
239
|
async scopesOf(accountId) {
|
|
219
|
-
return (await query(
|
|
240
|
+
return (await query(
|
|
241
|
+
"SELECT scope_id, role FROM memberships WHERE account_id = $1 ORDER BY created_at",
|
|
242
|
+
[accountId]
|
|
243
|
+
)).map((r) => ({ scopeId: r.scope_id, role: r.role }));
|
|
220
244
|
},
|
|
221
245
|
/** The person's role in the scope if it holds the permission; otherwise `NOT_A_MEMBER` (404: nothing tells the scope exists) or `FORBIDDEN`. */
|
|
222
246
|
async authorize(accountId, scopeId, permission) {
|
|
@@ -238,7 +262,13 @@ function createRoles(deps) {
|
|
|
238
262
|
"SELECT m.account_id, a.email, a.name, m.role, m.created_at FROM memberships m JOIN accounts a ON a.id = m.account_id WHERE m.scope_id = $1 ORDER BY m.created_at",
|
|
239
263
|
[scopeId]
|
|
240
264
|
);
|
|
241
|
-
return rows.map((r) => ({
|
|
265
|
+
return rows.map((r) => ({
|
|
266
|
+
accountId: r.account_id,
|
|
267
|
+
email: r.email,
|
|
268
|
+
name: r.name,
|
|
269
|
+
role: r.role,
|
|
270
|
+
since: r.created_at
|
|
271
|
+
}));
|
|
242
272
|
},
|
|
243
273
|
/** Changes someone's role. The actor must manage both the current and the new role; the last owner can never be demoted. */
|
|
244
274
|
async setRole(scopeId, actor, target, role) {
|
|
@@ -246,8 +276,10 @@ function createRoles(deps) {
|
|
|
246
276
|
const mine = await roleOf(actor.id, scopeId);
|
|
247
277
|
const current = await roleOf(target, scopeId);
|
|
248
278
|
if (!mine || !current) throw new RolesError(ROLES_CODE.notMember);
|
|
249
|
-
if (!model.manages[mine].includes(current) || !model.manages[mine].includes(role))
|
|
250
|
-
|
|
279
|
+
if (!model.manages[mine].includes(current) || !model.manages[mine].includes(role))
|
|
280
|
+
throw new RolesError(ROLES_CODE.notAllowed);
|
|
281
|
+
if (current === ownerRole() && role !== current && (await owners(scopeId)).length <= 1)
|
|
282
|
+
throw new RolesError(ROLES_CODE.lastOwner);
|
|
251
283
|
await query("UPDATE memberships SET role = $3 WHERE scope_id = $1 AND account_id = $2", [scopeId, target, role]);
|
|
252
284
|
},
|
|
253
285
|
/** Removes someone from the scope (or leaves it, when the target is the actor). The last owner cannot go. */
|
|
@@ -266,22 +298,50 @@ function createRoles(deps) {
|
|
|
266
298
|
if (!mine) throw new RolesError(ROLES_CODE.notMember);
|
|
267
299
|
if (!model.manages[mine].includes(role)) throw new RolesError(ROLES_CODE.notAllowed);
|
|
268
300
|
const email = input.email.trim().toLowerCase();
|
|
269
|
-
await query("UPDATE invitations SET used_at = $3 WHERE scope_id = $1 AND email = $2 AND used_at IS NULL", [
|
|
301
|
+
await query("UPDATE invitations SET used_at = $3 WHERE scope_id = $1 AND email = $2 AND used_at IS NULL", [
|
|
302
|
+
scopeId,
|
|
303
|
+
email,
|
|
304
|
+
now
|
|
305
|
+
]);
|
|
270
306
|
const token = newToken();
|
|
271
307
|
const [row] = await query(
|
|
272
308
|
"INSERT INTO invitations (id, scope_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, expires_at",
|
|
273
|
-
[
|
|
309
|
+
[
|
|
310
|
+
crypto3.randomUUID(),
|
|
311
|
+
scopeId,
|
|
312
|
+
email,
|
|
313
|
+
role,
|
|
314
|
+
deps.tokenHash(token),
|
|
315
|
+
actor.id,
|
|
316
|
+
now,
|
|
317
|
+
addHours(now, deps.inviteTtlHours)
|
|
318
|
+
]
|
|
274
319
|
);
|
|
275
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
token,
|
|
322
|
+
invitation: { id: row.id, email, role, createdAt: row.created_at, expiresAt: row.expires_at }
|
|
323
|
+
};
|
|
276
324
|
},
|
|
277
325
|
async pendingInvitations(scopeId, now) {
|
|
278
|
-
const rows = await query(
|
|
279
|
-
|
|
326
|
+
const rows = await query(
|
|
327
|
+
"SELECT id, email, role, created_at, expires_at FROM invitations WHERE scope_id = $1 AND used_at IS NULL AND expires_at > $2 ORDER BY created_at",
|
|
328
|
+
[scopeId, now]
|
|
329
|
+
);
|
|
330
|
+
return rows.map((r) => ({
|
|
331
|
+
id: r.id,
|
|
332
|
+
email: r.email,
|
|
333
|
+
role: r.role,
|
|
334
|
+
createdAt: r.created_at,
|
|
335
|
+
expiresAt: r.expires_at
|
|
336
|
+
}));
|
|
280
337
|
},
|
|
281
338
|
async revokeInvitation(scopeId, actor, invitationId, now) {
|
|
282
339
|
const mine = await roleOf(actor.id, scopeId);
|
|
283
340
|
if (!mine) throw new RolesError(ROLES_CODE.notMember);
|
|
284
|
-
const [row] = await query(
|
|
341
|
+
const [row] = await query(
|
|
342
|
+
"SELECT role FROM invitations WHERE id = $1 AND scope_id = $2 AND used_at IS NULL",
|
|
343
|
+
[invitationId, scopeId]
|
|
344
|
+
);
|
|
285
345
|
if (!row) throw new RolesError(ROLES_CODE.inviteInvalid);
|
|
286
346
|
if (!model.manages[mine].includes(row.role)) throw new RolesError(ROLES_CODE.notAllowed);
|
|
287
347
|
await query("UPDATE invitations SET used_at = $2 WHERE id = $1", [invitationId, now]);
|
|
@@ -296,8 +356,15 @@ function createRoles(deps) {
|
|
|
296
356
|
if (row.email !== account.email.trim().toLowerCase()) throw new RolesError(ROLES_CODE.inviteWrongAccount);
|
|
297
357
|
await query("UPDATE invitations SET used_at = $2 WHERE id = $1", [row.id, now]);
|
|
298
358
|
const current = await roleOf(account.id, row.scope_id);
|
|
299
|
-
if (!current || model.manages[current].length < model.manages[row.role].length)
|
|
300
|
-
|
|
359
|
+
if (!current || model.manages[current].length < model.manages[row.role].length)
|
|
360
|
+
await query(
|
|
361
|
+
"INSERT INTO memberships (scope_id, account_id, role, invited_by, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (scope_id, account_id) DO UPDATE SET role = excluded.role",
|
|
362
|
+
[row.scope_id, account.id, row.role, row.invited_by, now]
|
|
363
|
+
);
|
|
364
|
+
return {
|
|
365
|
+
scopeId: row.scope_id,
|
|
366
|
+
role: !current || model.manages[current].length < model.manages[row.role].length ? row.role : current
|
|
367
|
+
};
|
|
301
368
|
}
|
|
302
369
|
};
|
|
303
370
|
}
|
|
@@ -308,7 +375,8 @@ var BODY = /^[A-Za-z0-9_-]{43}$/;
|
|
|
308
375
|
var PREFIX = /^[a-z]{1,4}$/;
|
|
309
376
|
var USER_AGENT_MAX = 300;
|
|
310
377
|
function createSessions(deps) {
|
|
311
|
-
if (!PREFIX.test(deps.tokenPrefix))
|
|
378
|
+
if (!PREFIX.test(deps.tokenPrefix))
|
|
379
|
+
throw new RangeError(`tokenPrefix must be 1 to 4 lower-case letters, got ${deps.tokenPrefix}`);
|
|
312
380
|
const head = `${deps.tokenPrefix}1.`;
|
|
313
381
|
const secret = () => typeof deps.secret === "function" ? deps.secret() : deps.secret;
|
|
314
382
|
const isToken = (value) => !!value && value.startsWith(head) && BODY.test(value.slice(head.length));
|
|
@@ -320,12 +388,20 @@ function createSessions(deps) {
|
|
|
320
388
|
tokenHash,
|
|
321
389
|
async create(accountId, meta) {
|
|
322
390
|
const days = typeof deps.sessionDays === "function" ? await deps.sessionDays() : deps.sessionDays;
|
|
323
|
-
if (!Number.isInteger(days) || days < 1)
|
|
391
|
+
if (!Number.isInteger(days) || days < 1)
|
|
392
|
+
throw new RangeError(`sessionDays must be a whole number of days from 1, got ${days}`);
|
|
324
393
|
const token = newToken();
|
|
325
394
|
const createdAt = deps.clock.now();
|
|
326
395
|
const expires = new Date(createdAt);
|
|
327
396
|
expires.setUTCHours(expires.getUTCHours() + days * 24);
|
|
328
|
-
await deps.store.insert({
|
|
397
|
+
await deps.store.insert({
|
|
398
|
+
accountId,
|
|
399
|
+
tokenHash: tokenHash(token),
|
|
400
|
+
createdAt,
|
|
401
|
+
expiresAt: expires,
|
|
402
|
+
ip: meta.ip,
|
|
403
|
+
userAgent: meta.userAgent?.slice(0, USER_AGENT_MAX) ?? null
|
|
404
|
+
});
|
|
329
405
|
return { token, expires };
|
|
330
406
|
},
|
|
331
407
|
async resolve(token) {
|
|
@@ -339,7 +415,15 @@ function createSessions(deps) {
|
|
|
339
415
|
};
|
|
340
416
|
}
|
|
341
417
|
function sessionCookie(token, expires, options) {
|
|
342
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
name: options.name,
|
|
420
|
+
value: token,
|
|
421
|
+
httpOnly: true,
|
|
422
|
+
secure: options.secure,
|
|
423
|
+
sameSite: "lax",
|
|
424
|
+
path: "/",
|
|
425
|
+
expires
|
|
426
|
+
};
|
|
343
427
|
}
|
|
344
428
|
function clearedSessionCookie(options, now) {
|
|
345
429
|
return sessionCookie("", now, options);
|
|
@@ -385,7 +469,10 @@ function dbSessionStore(query, options) {
|
|
|
385
469
|
const active = options.active ? ` AND a.${ident2(options.active.column)} = $3` : "";
|
|
386
470
|
return {
|
|
387
471
|
async insert(session) {
|
|
388
|
-
await query(
|
|
472
|
+
await query(
|
|
473
|
+
`INSERT INTO ${sessions} (account_id, token_hash, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
474
|
+
[session.accountId, session.tokenHash, session.createdAt, session.expiresAt, session.ip, session.userAgent]
|
|
475
|
+
);
|
|
389
476
|
},
|
|
390
477
|
async find(tokenHash, now) {
|
|
391
478
|
const params = [tokenHash, now];
|
|
@@ -412,7 +499,8 @@ function parseExternalIdentity(body) {
|
|
|
412
499
|
const o = body ?? {};
|
|
413
500
|
const issuer = text(o.issuer);
|
|
414
501
|
const subject = text(o.subject);
|
|
415
|
-
if (!issuer || !subject)
|
|
502
|
+
if (!issuer || !subject)
|
|
503
|
+
throw new FederationError(FEDERATION_CODE.unavailable, "krizaka-users answered without an issuer and a subject");
|
|
416
504
|
return { issuer, subject, email: text(o.email), emailVerified: o.email_verified === true, name: text(o.name) };
|
|
417
505
|
}
|
|
418
506
|
function usersIdentityProvider({ baseUrl, appUrl, fetch }) {
|
|
@@ -420,8 +508,10 @@ function usersIdentityProvider({ baseUrl, appUrl, fetch }) {
|
|
|
420
508
|
const app = appUrl.replace(/\/+$/, "");
|
|
421
509
|
return {
|
|
422
510
|
async start(provider, returnTo, options = {}) {
|
|
423
|
-
if (!isFederatedProvider(provider))
|
|
424
|
-
|
|
511
|
+
if (!isFederatedProvider(provider))
|
|
512
|
+
throw new FederationError(FEDERATION_CODE.unknownProvider, `Unknown provider ${String(provider)}`);
|
|
513
|
+
if (!isLocalReturn(returnTo))
|
|
514
|
+
throw new FederationError(FEDERATION_CODE.badReturn, "That return address is not allowed");
|
|
425
515
|
const state = crypto.randomUUID();
|
|
426
516
|
const query = new URLSearchParams({ return_to: `${app}${returnTo}`, state });
|
|
427
517
|
if (options.codeChallenge) {
|
|
@@ -436,8 +526,10 @@ function usersIdentityProvider({ baseUrl, appUrl, fetch }) {
|
|
|
436
526
|
headers: { "content-type": "application/json" },
|
|
437
527
|
body: JSON.stringify({ code, ...options.codeVerifier ? { code_verifier: options.codeVerifier } : {} })
|
|
438
528
|
});
|
|
439
|
-
if (response.status === 400 || response.status === 401 || response.status === 404 || response.status === 410)
|
|
440
|
-
|
|
529
|
+
if (response.status === 400 || response.status === 401 || response.status === 404 || response.status === 410)
|
|
530
|
+
throw new FederationError(FEDERATION_CODE.invalidCode, "That code is not valid");
|
|
531
|
+
if (!response.ok)
|
|
532
|
+
throw new FederationError(FEDERATION_CODE.unavailable, `krizaka-users answered ${response.status}`);
|
|
441
533
|
return parseExternalIdentity(await response.json());
|
|
442
534
|
}
|
|
443
535
|
};
|