@wtfalch/auth 0.31.0 → 0.33.0
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 +103 -1
- package/dist/auth.d.ts +4 -0
- package/dist/auth.js +13 -42
- package/dist/body.d.ts +5 -0
- package/dist/body.js +35 -0
- package/dist/cookies.d.ts +4 -0
- package/dist/cookies.js +10 -5
- package/dist/desktop-crypto.d.ts +26 -0
- package/dist/desktop-crypto.js +62 -0
- package/dist/desktop-store-contract.d.ts +13 -0
- package/dist/desktop-store-contract.js +106 -0
- package/dist/desktop-store-postgres.d.ts +40 -0
- package/dist/desktop-store-postgres.js +133 -0
- package/dist/desktop-store.d.ts +130 -0
- package/dist/desktop-store.js +111 -0
- package/dist/desktop.d.ts +11 -76
- package/dist/desktop.js +156 -286
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/preview.d.ts +28 -0
- package/dist/preview.js +56 -0
- package/dist/redirect.d.ts +9 -0
- package/dist/redirect.js +14 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +2 -0
- package/dist/workspaces.d.ts +79 -0
- package/dist/workspaces.js +137 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -293,9 +293,43 @@ at `/auth/desktop/redeem`.
|
|
|
293
293
|
whoever the issuer signed in, with no further check. It fails open: an app
|
|
294
294
|
that needs an organisation, second-factor or linked-principal check and leaves
|
|
295
295
|
`admit` out gets no warning. Pass the check explicitly.
|
|
296
|
+
- **The store.** A multi-replica app passes its Postgres client to
|
|
297
|
+
`createPostgresDesktopHandoffStore(client, { capacity, ttlMs, pollGapMs })`
|
|
298
|
+
and runs `DESKTOP_HANDOFFS_TABLE_SQL` once in a migration. The client is two
|
|
299
|
+
methods, so any driver fits: `query(text, values)` resolves to the rows, and
|
|
300
|
+
`transaction(fn)` runs `fn` with the same `query` shape on one connection,
|
|
301
|
+
committing when `fn` returns and rolling back when it throws. With `pg`:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
const client: DesktopHandoffSqlClient = {
|
|
305
|
+
query: async (text, values) => (await pool.query(text, [...values])).rows,
|
|
306
|
+
async transaction(fn) {
|
|
307
|
+
const connection = await pool.connect();
|
|
308
|
+
try {
|
|
309
|
+
await connection.query('BEGIN');
|
|
310
|
+
const result = await fn({
|
|
311
|
+
query: async (text, values) => (await connection.query(text, [...values])).rows,
|
|
312
|
+
});
|
|
313
|
+
await connection.query('COMMIT');
|
|
314
|
+
return result;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
await connection.query('ROLLBACK');
|
|
317
|
+
throw error;
|
|
318
|
+
} finally {
|
|
319
|
+
connection.release();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The table is Boule's and Archon's `desktop_handoffs` with a `caller_hash text`
|
|
326
|
+
column; an app that has the table without it runs
|
|
327
|
+
`ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS caller_hash text`. Test
|
|
328
|
+
any store, this one included, with `checkDesktopHandoffStore`.
|
|
296
329
|
- **The store must lock.** `lock(tx, id)` is `SELECT ... FOR UPDATE` in a real
|
|
297
330
|
store. The memory store locks per row; a store that only reads lets two
|
|
298
|
-
concurrent redeems both get the cookie.
|
|
331
|
+
concurrent redeems both get the cookie. `createMemoryDesktopHandoffStore`
|
|
332
|
+
serves one process only.
|
|
299
333
|
- **Rate-limit `/auth/desktop/start` at the edge too.** The handler caps open
|
|
300
334
|
sign-ins per caller (`maxStartsPerCaller`, default 8, keyed on the address in
|
|
301
335
|
`clientAddressHeader`, default `cf-connecting-ip`) under the store's total
|
|
@@ -433,6 +467,74 @@ A phone that is already signed in can also approve a new device from a QR
|
|
|
433
467
|
code, the issuer's OAuth device grant opted into per namespace binding with
|
|
434
468
|
`qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
|
|
435
469
|
|
|
470
|
+
## Workspaces for your app's customers
|
|
471
|
+
|
|
472
|
+
An app with `workspaces` on its entry gets `WORKSPACES_URL`, `WORKSPACES_API_KEY`
|
|
473
|
+
and `AUTH_ORGANIZATION_ID`. `@wtfalch/auth/workspaces` calls the broker with them.
|
|
474
|
+
The app keeps the current workspace id itself; this one holds it in a cookie.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
import { cookies } from 'next/headers';
|
|
478
|
+
import { workspaces, WorkspacesError } from '@wtfalch/auth/workspaces';
|
|
479
|
+
import { auth } from './auth'; // your createAuth() instance
|
|
480
|
+
|
|
481
|
+
const ws = workspaces(); // throws at once if an env value is missing
|
|
482
|
+
|
|
483
|
+
export async function currentWorkspace(request: Request) {
|
|
484
|
+
const { user } = await auth.read(request);
|
|
485
|
+
if (!user) return null;
|
|
486
|
+
const id = (await cookies()).get('workspace')?.value;
|
|
487
|
+
const role = id ? await ws.access(id, user) : null; // never trust the cookie alone
|
|
488
|
+
return id && role ? { id, role } : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function createWorkspace(request: Request, name: string) {
|
|
492
|
+
const { user } = await auth.read(request);
|
|
493
|
+
if (!user) throw new Error('Sign in first.');
|
|
494
|
+
const made = await ws.create({ name, owner: user });
|
|
495
|
+
(await cookies()).set('workspace', made.id, { httpOnly: true, sameSite: 'lax', secure: true });
|
|
496
|
+
return made;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function inviteBy(request: Request, id: string, email: string) {
|
|
500
|
+
const { user } = await auth.read(request);
|
|
501
|
+
if (!user) throw new Error('Sign in first.');
|
|
502
|
+
try {
|
|
503
|
+
await ws.invite(id, { email, role: 'member', actor: user }); // the broker mails the link
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (error instanceof WorkspacesError && error.status === 403) return 'Only an owner can invite.';
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// On the page behind `inviteUrl?token=`: await ws.accept(token, user) -> { workspaceId, role }
|
|
510
|
+
|
|
511
|
+
// Any member: who is in the workspace -> [{ sub, email, name, role }] (name is null; email is '' only for a creator from before this existed)
|
|
512
|
+
await ws.members(id, user);
|
|
513
|
+
// Owner only:
|
|
514
|
+
await ws.invitations(id, user); // pending -> [{ id, email, role, createdAt, expiresAt }]
|
|
515
|
+
await ws.revokeInvitation(id, invitationId, user); // the emailed link stops working
|
|
516
|
+
await ws.rename(id, 'New name', user);
|
|
517
|
+
await ws.archive(id, user); // leaves listFor; every later call on it fails with status 410
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## Preview routes
|
|
521
|
+
|
|
522
|
+
For apps on live/idle releases. The balancer sends a request with the `bg_preview=1` cookie to the idle slot, after asking the live slot whether the person may see it.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
import { createPreviewRoutes } from '@wtfalch/auth';
|
|
526
|
+
|
|
527
|
+
export const preview = createPreviewRoutes({
|
|
528
|
+
auth, // the app's own `createAuth(...)` instance
|
|
529
|
+
mayPreview: (user) => canPreview(user), // `releases:preview` in the app's production authz
|
|
530
|
+
});
|
|
531
|
+
// GET /api/foundry/preview-check -> preview.check (200, 401 no session, 403 otherwise)
|
|
532
|
+
// POST /api/foundry/preview -> preview.set (204 and the cookie, same-origin only)
|
|
533
|
+
// DELETE /api/foundry/preview -> preview.clear (204, same-origin only, no permission needed)
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Answers carry no body and `cache-control: no-store`. A reader or permission check that throws answers 403 and logs one fixed line with the error name only. The session is read through `auth` (without refreshing), and the trusted origin and the cookie's `Secure` rule come from its `appUrl`, never from the request. They are read on the first request, so `next build` importing this module with no environment set does not throw. A request from another origin is refused with 403 and no log line. `PREVIEW_COOKIE`, `PREVIEW_CHECK_PATH` and `PREVIEW_PATH` are exported too.
|
|
537
|
+
|
|
436
538
|
## Documentation
|
|
437
539
|
|
|
438
540
|
`docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
|
package/dist/auth.d.ts
CHANGED
|
@@ -195,6 +195,10 @@ export interface QrPollResult {
|
|
|
195
195
|
}
|
|
196
196
|
export interface Auth {
|
|
197
197
|
readonly sessionCookieName: string;
|
|
198
|
+
/** The app's own origin, from `appUrl`. Read on first use, so it throws only if `appUrl` is missing or malformed. */
|
|
199
|
+
readonly appOrigin: string;
|
|
200
|
+
/** Whether this app's cookies carry `Secure`: true when `appUrl` is https. The same rule the session cookie uses. */
|
|
201
|
+
readonly cookiesSecure: boolean;
|
|
198
202
|
/** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
|
|
199
203
|
start(request: Request): Promise<Response>;
|
|
200
204
|
/** GET {basePath}/callback */
|
package/dist/auth.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import * as client from 'openid-client';
|
|
2
|
+
import { declaredWithin, readBounded } from './body.js';
|
|
2
3
|
import { Broker, BrokerError } from './broker.js';
|
|
3
4
|
import { resolveOptions, } from './config.js';
|
|
4
5
|
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
|
|
5
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
6
7
|
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
|
|
7
8
|
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
8
|
-
import { safeLoginHint, safeNextPath } from './redirect.js';
|
|
9
|
-
import { expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
|
|
9
|
+
import { isSameOrigin, safeLoginHint, safeNextPath } from './redirect.js';
|
|
10
|
+
import { NO_STORE, expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
|
|
10
11
|
export function createAuth(input) {
|
|
11
12
|
// Resolved on first use so `next build`, which imports every route module, needs none of the values set.
|
|
12
13
|
let resolved = null;
|
|
@@ -142,11 +143,11 @@ export function createAuth(input) {
|
|
|
142
143
|
const type = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
|
143
144
|
if (type !== 'application/x-www-form-urlencoded')
|
|
144
145
|
return new Response('Unsupported Media Type', { status: 415 });
|
|
145
|
-
|
|
146
|
-
if (length === null || !/^\d{1,9}$/.test(length) || Number(length) > START_BODY_MAX)
|
|
146
|
+
if (!declaredWithin(request, START_BODY_MAX))
|
|
147
147
|
return new Response('Payload Too Large', { status: 413 });
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// The declared length is a hint; the read itself stops at the cap.
|
|
149
|
+
const text = await readBounded(request, START_BODY_MAX);
|
|
150
|
+
if (text === null)
|
|
150
151
|
return new Response('Payload Too Large', { status: 413 });
|
|
151
152
|
return new URLSearchParams(text);
|
|
152
153
|
};
|
|
@@ -1032,6 +1033,12 @@ export function createAuth(input) {
|
|
|
1032
1033
|
get sessionCookieName() {
|
|
1033
1034
|
return sessionCookieName(options());
|
|
1034
1035
|
},
|
|
1036
|
+
get appOrigin() {
|
|
1037
|
+
return options().appUrl.origin;
|
|
1038
|
+
},
|
|
1039
|
+
get cookiesSecure() {
|
|
1040
|
+
return options().secure;
|
|
1041
|
+
},
|
|
1035
1042
|
start,
|
|
1036
1043
|
callback,
|
|
1037
1044
|
complete,
|
|
@@ -1087,38 +1094,8 @@ function redirect(location, cookies, status = 302) {
|
|
|
1087
1094
|
headers.append('Set-Cookie', cookie.header);
|
|
1088
1095
|
return new Response(null, { status, headers });
|
|
1089
1096
|
}
|
|
1090
|
-
const NO_STORE = { 'Cache-Control': 'no-store' };
|
|
1091
1097
|
/** A logout token is a few hundred bytes; this is generous and still small. */
|
|
1092
1098
|
const BACKCHANNEL_LOGOUT_MAX_BODY_BYTES = 16 * 1024;
|
|
1093
|
-
/** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
|
|
1094
|
-
async function readBounded(request, max) {
|
|
1095
|
-
const declared = Number(request.headers.get('content-length'));
|
|
1096
|
-
if (Number.isFinite(declared) && declared > max)
|
|
1097
|
-
return null;
|
|
1098
|
-
if (!request.body)
|
|
1099
|
-
return '';
|
|
1100
|
-
const reader = request.body.getReader();
|
|
1101
|
-
const chunks = [];
|
|
1102
|
-
let size = 0;
|
|
1103
|
-
for (;;) {
|
|
1104
|
-
const { done, value } = await reader.read();
|
|
1105
|
-
if (done)
|
|
1106
|
-
break;
|
|
1107
|
-
size += value.byteLength;
|
|
1108
|
-
if (size > max) {
|
|
1109
|
-
await reader.cancel();
|
|
1110
|
-
return null;
|
|
1111
|
-
}
|
|
1112
|
-
chunks.push(value);
|
|
1113
|
-
}
|
|
1114
|
-
const bytes = new Uint8Array(size);
|
|
1115
|
-
let offset = 0;
|
|
1116
|
-
for (const chunk of chunks) {
|
|
1117
|
-
bytes.set(chunk, offset);
|
|
1118
|
-
offset += chunk.byteLength;
|
|
1119
|
-
}
|
|
1120
|
-
return new TextDecoder().decode(bytes);
|
|
1121
|
-
}
|
|
1122
1099
|
/** A back-channel logout endpoint's 400 error body, per OpenID Connect Back-Channel Logout 1.0 §2.6. */
|
|
1123
1100
|
function backchannelLogoutError(error, description, status = 400) {
|
|
1124
1101
|
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
@@ -1210,12 +1187,6 @@ function isNavigation(request) {
|
|
|
1210
1187
|
return false;
|
|
1211
1188
|
return !request.headers.has('rsc') && !request.headers.has('next-action');
|
|
1212
1189
|
}
|
|
1213
|
-
function isSameOrigin(request, origin) {
|
|
1214
|
-
const from = request.headers.get('origin');
|
|
1215
|
-
if (from)
|
|
1216
|
-
return from === origin;
|
|
1217
|
-
return request.headers.get('sec-fetch-site') === 'same-origin';
|
|
1218
|
-
}
|
|
1219
1190
|
// Never says whether the address exists, so neither can the app.
|
|
1220
1191
|
async function quietly(act) {
|
|
1221
1192
|
try {
|
package/dist/body.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Bounded request bodies: the one place the package decides how much of a request it will read. */
|
|
2
|
+
/** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
|
|
3
|
+
export declare function declaredWithin(request: Request, max: number): boolean;
|
|
4
|
+
/** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
|
|
5
|
+
export declare function readBounded(request: Request, max: number): Promise<string | null>;
|
package/dist/body.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Bounded request bodies: the one place the package decides how much of a request it will read. */
|
|
2
|
+
/** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
|
|
3
|
+
export function declaredWithin(request, max) {
|
|
4
|
+
const length = request.headers.get('content-length');
|
|
5
|
+
return length !== null && /^\d{1,9}$/.test(length) && Number(length) <= max;
|
|
6
|
+
}
|
|
7
|
+
/** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
|
|
8
|
+
export async function readBounded(request, max) {
|
|
9
|
+
const declared = Number(request.headers.get('content-length'));
|
|
10
|
+
if (Number.isFinite(declared) && declared > max)
|
|
11
|
+
return null;
|
|
12
|
+
if (!request.body)
|
|
13
|
+
return '';
|
|
14
|
+
const reader = request.body.getReader();
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { done, value } = await reader.read();
|
|
19
|
+
if (done)
|
|
20
|
+
break;
|
|
21
|
+
size += value.byteLength;
|
|
22
|
+
if (size > max) {
|
|
23
|
+
await reader.cancel();
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
chunks.push(value);
|
|
27
|
+
}
|
|
28
|
+
const bytes = new Uint8Array(size);
|
|
29
|
+
let offset = 0;
|
|
30
|
+
for (const chunk of chunks) {
|
|
31
|
+
bytes.set(chunk, offset);
|
|
32
|
+
offset += chunk.byteLength;
|
|
33
|
+
}
|
|
34
|
+
return new TextDecoder().decode(bytes);
|
|
35
|
+
}
|
package/dist/cookies.d.ts
CHANGED
|
@@ -78,6 +78,10 @@ export declare function linkCookieName(options: ResolvedOptions): string;
|
|
|
78
78
|
export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
79
79
|
export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
|
|
80
80
|
export declare function clearLink(options: ResolvedOptions): SetCookie;
|
|
81
|
+
/** The header that makes the browser drop the cookie `name`. */
|
|
82
|
+
export declare function clearCookie(name: string, secure: boolean): SetCookie;
|
|
83
|
+
/** Builds the header. A `maxAge` of 0 or less is the clearing form; callers clear through `clearCookie`. */
|
|
84
|
+
export declare function setCookie(name: string, value: string, maxAge: number, secure: boolean): SetCookie;
|
|
81
85
|
export declare function cookieFrom(header: string | null, name: string): string | undefined;
|
|
82
86
|
/** The Cookie header with these cookies replaced or removed, for a proxy to forward. */
|
|
83
87
|
export declare function withCookies(header: string | null, updates: SetCookie[]): string;
|
package/dist/cookies.js
CHANGED
|
@@ -51,7 +51,7 @@ export async function openSession(options, value) {
|
|
|
51
51
|
return payload && typeof payload.idt === 'string' ? payload : null;
|
|
52
52
|
}
|
|
53
53
|
export function clearSession(options) {
|
|
54
|
-
return
|
|
54
|
+
return clearCookie(sessionCookieName(options), options.secure);
|
|
55
55
|
}
|
|
56
56
|
/** The namespace session cookie: a random handle to a server record, nothing else. */
|
|
57
57
|
export async function sealHandle(options, sid) {
|
|
@@ -96,7 +96,7 @@ export async function openTransaction(options, value) {
|
|
|
96
96
|
: null;
|
|
97
97
|
}
|
|
98
98
|
export function clearTransaction(options) {
|
|
99
|
-
return
|
|
99
|
+
return clearCookie(transactionCookieName(options), options.secure);
|
|
100
100
|
}
|
|
101
101
|
export function silentCookieName(options) {
|
|
102
102
|
return options.secure ? `__Host-${SILENT}` : SILENT;
|
|
@@ -126,7 +126,7 @@ export async function openQrTransaction(options, value) {
|
|
|
126
126
|
return payload && typeof payload.dc === 'string' ? payload : null;
|
|
127
127
|
}
|
|
128
128
|
export function clearQrTransaction(options) {
|
|
129
|
-
return
|
|
129
|
+
return clearCookie(qrTransactionCookieName(options), options.secure);
|
|
130
130
|
}
|
|
131
131
|
export function linkCookieName(options) {
|
|
132
132
|
return options.secure ? `__Host-${LINK}` : LINK;
|
|
@@ -145,7 +145,7 @@ export async function openLink(options, value) {
|
|
|
145
145
|
: null;
|
|
146
146
|
}
|
|
147
147
|
export function clearLink(options) {
|
|
148
|
-
return
|
|
148
|
+
return clearCookie(linkCookieName(options), options.secure);
|
|
149
149
|
}
|
|
150
150
|
// A different audience also prevents old static consumers from accepting a
|
|
151
151
|
// namespace cookie. Hashing keeps the encrypted session below browser limits.
|
|
@@ -186,7 +186,12 @@ async function open(options, kind, value) {
|
|
|
186
186
|
return null;
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
|
|
189
|
+
/** The header that makes the browser drop the cookie `name`. */
|
|
190
|
+
export function clearCookie(name, secure) {
|
|
191
|
+
return setCookie(name, '', 0, secure);
|
|
192
|
+
}
|
|
193
|
+
/** Builds the header. A `maxAge` of 0 or less is the clearing form; callers clear through `clearCookie`. */
|
|
194
|
+
export function setCookie(name, value, maxAge, secure) {
|
|
190
195
|
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
191
196
|
if (maxAge > 0) {
|
|
192
197
|
parts.push(`Max-Age=${maxAge}`);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const newNonce: () => string;
|
|
2
|
+
export declare function same(a: string, b: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* The key that seals a payload, derived from the cookie secret the app
|
|
5
|
+
* already holds, so no new secret exists. The fixed label keeps it apart
|
|
6
|
+
* from every other use of that secret. It is not the label Boule and Archon
|
|
7
|
+
* used (`boule-desktop-handoff`, `archon-desktop-handoff`): a row sealed under
|
|
8
|
+
* the old label fails to open here, so a sign-in in flight when an app cuts
|
|
9
|
+
* over to this handler ends in `payload_invalid`. The window is the row's
|
|
10
|
+
* lifetime, five minutes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function sealKey(secret: string): Buffer;
|
|
13
|
+
/** AES-256-GCM. `aad` is authenticated, not secret: it ties the ciphertext to one row and one origin. */
|
|
14
|
+
export declare function seal(key: Buffer, aad: string, plaintext: string): string;
|
|
15
|
+
/** Throws when the ciphertext, the tag or the `aad` is not what `seal` was given. */
|
|
16
|
+
export declare function open(key: Buffer, aad: string, sealed: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* The code the browser and the shell each derive from the challenge, so the
|
|
19
|
+
* person can see both belong to one sign-in: the first 30 bits of
|
|
20
|
+
* sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as `ABC-DEF`.
|
|
21
|
+
* The label is fixed text, not this app's id: it is shared with
|
|
22
|
+
* `@wtfalch/desktop`'s `login.mjs`, which derives the same code
|
|
23
|
+
* independently of which app it is signing into. Changing it breaks pairing
|
|
24
|
+
* for every shell already built against it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pairingCode(challenge: string): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual, } from 'node:crypto';
|
|
2
|
+
/*
|
|
3
|
+
* The handoff's crypto, apart from its store and its handler. It uses
|
|
4
|
+
* `node:crypto` on purpose: the sealed payload's format (`iv|tag|ciphertext`,
|
|
5
|
+
* AES-256-GCM) is what rows already in an app's store hold, and `cookies.ts`'s
|
|
6
|
+
* jose sealing is another format. Only the `./desktop`
|
|
7
|
+
* entry imports this file, so the root entry stays free of `node:crypto`.
|
|
8
|
+
*/
|
|
9
|
+
export const newNonce = () => randomBytes(32).toString('base64url');
|
|
10
|
+
export function same(a, b) {
|
|
11
|
+
const x = Buffer.from(a);
|
|
12
|
+
const y = Buffer.from(b);
|
|
13
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The key that seals a payload, derived from the cookie secret the app
|
|
17
|
+
* already holds, so no new secret exists. The fixed label keeps it apart
|
|
18
|
+
* from every other use of that secret. It is not the label Boule and Archon
|
|
19
|
+
* used (`boule-desktop-handoff`, `archon-desktop-handoff`): a row sealed under
|
|
20
|
+
* the old label fails to open here, so a sign-in in flight when an app cuts
|
|
21
|
+
* over to this handler ends in `payload_invalid`. The window is the row's
|
|
22
|
+
* lifetime, five minutes.
|
|
23
|
+
*/
|
|
24
|
+
export function sealKey(secret) {
|
|
25
|
+
return Buffer.from(hkdfSync('sha256', secret, Buffer.alloc(0), 'wtfalch-auth-desktop-handoff', 32));
|
|
26
|
+
}
|
|
27
|
+
/** AES-256-GCM. `aad` is authenticated, not secret: it ties the ciphertext to one row and one origin. */
|
|
28
|
+
export function seal(key, aad, plaintext) {
|
|
29
|
+
const iv = randomBytes(12);
|
|
30
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
31
|
+
cipher.setAAD(Buffer.from(aad));
|
|
32
|
+
const data = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
33
|
+
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64url');
|
|
34
|
+
}
|
|
35
|
+
/** Throws when the ciphertext, the tag or the `aad` is not what `seal` was given. */
|
|
36
|
+
export function open(key, aad, sealed) {
|
|
37
|
+
const data = Buffer.from(sealed, 'base64url');
|
|
38
|
+
const decipher = createDecipheriv('aes-256-gcm', key, data.subarray(0, 12));
|
|
39
|
+
decipher.setAAD(Buffer.from(aad));
|
|
40
|
+
decipher.setAuthTag(data.subarray(12, 28));
|
|
41
|
+
return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString('utf8');
|
|
42
|
+
}
|
|
43
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
44
|
+
/**
|
|
45
|
+
* The code the browser and the shell each derive from the challenge, so the
|
|
46
|
+
* person can see both belong to one sign-in: the first 30 bits of
|
|
47
|
+
* sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as `ABC-DEF`.
|
|
48
|
+
* The label is fixed text, not this app's id: it is shared with
|
|
49
|
+
* `@wtfalch/desktop`'s `login.mjs`, which derives the same code
|
|
50
|
+
* independently of which app it is signing into. Changing it breaks pairing
|
|
51
|
+
* for every shell already built against it.
|
|
52
|
+
*/
|
|
53
|
+
export function pairingCode(challenge) {
|
|
54
|
+
const bits = createHash('sha256')
|
|
55
|
+
.update(`boule-desktop-pair:${challenge}`)
|
|
56
|
+
.digest()
|
|
57
|
+
.readUInt32BE(0);
|
|
58
|
+
let code = '';
|
|
59
|
+
for (let i = 0; i < 6; i++)
|
|
60
|
+
code += CROCKFORD[(bits >>> (27 - 5 * i)) & 31];
|
|
61
|
+
return `${code.slice(0, 3)}-${code.slice(3)}`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DesktopHandoffStore } from './desktop-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Checks a `DesktopHandoffStore` against the contract the handler relies on and
|
|
4
|
+
* cannot check itself: `admit`'s per-caller cap, `lock`'s exclusivity, the
|
|
5
|
+
* one-a-second `stampPoll` and a row's round trip through `patch` and
|
|
6
|
+
* `remove`. Run it against any store, the Postgres one included, with a
|
|
7
|
+
* function that makes a fresh store over an empty table (a store with
|
|
8
|
+
* `capacity` of at least 4 rows and the default poll gap). It throws an Error
|
|
9
|
+
* naming the broken rule, and uses no test framework.
|
|
10
|
+
*
|
|
11
|
+
* It does not check the store's total capacity, which is the store's own.
|
|
12
|
+
*/
|
|
13
|
+
export declare function checkDesktopHandoffStore(create: () => DesktopHandoffStore | Promise<DesktopHandoffStore>): Promise<void>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks a `DesktopHandoffStore` against the contract the handler relies on and
|
|
3
|
+
* cannot check itself: `admit`'s per-caller cap, `lock`'s exclusivity, the
|
|
4
|
+
* one-a-second `stampPoll` and a row's round trip through `patch` and
|
|
5
|
+
* `remove`. Run it against any store, the Postgres one included, with a
|
|
6
|
+
* function that makes a fresh store over an empty table (a store with
|
|
7
|
+
* `capacity` of at least 4 rows and the default poll gap). It throws an Error
|
|
8
|
+
* naming the broken rule, and uses no test framework.
|
|
9
|
+
*
|
|
10
|
+
* It does not check the store's total capacity, which is the store's own.
|
|
11
|
+
*/
|
|
12
|
+
export async function checkDesktopHandoffStore(create) {
|
|
13
|
+
const ensure = (ok, rule) => {
|
|
14
|
+
if (!ok)
|
|
15
|
+
throw new Error(`DesktopHandoffStore contract broken: ${rule}`);
|
|
16
|
+
};
|
|
17
|
+
let n = 0;
|
|
18
|
+
const row = (callerHash) => {
|
|
19
|
+
n += 1;
|
|
20
|
+
const id = `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
challenge: `c${n}`.padEnd(43, 'x'),
|
|
24
|
+
browserHash: `b${n}`,
|
|
25
|
+
stateHash: `s${n}`,
|
|
26
|
+
callerHash,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
// admit: the cap is per caller, and callers with no address share one bucket.
|
|
30
|
+
const capped = await create();
|
|
31
|
+
const limits = { perCaller: 2 };
|
|
32
|
+
ensure(await capped.admit(row('a'), limits), 'admit refused the first start of a caller');
|
|
33
|
+
ensure(await capped.admit(row('a'), limits), 'admit refused a start below perCaller');
|
|
34
|
+
ensure(!(await capped.admit(row('a'), limits)), 'admit ignored the per-caller cap');
|
|
35
|
+
ensure(await capped.admit(row('b'), limits), 'admit let one caller fill the store for another');
|
|
36
|
+
ensure(await capped.admit(row(null), limits), 'admit refused the first start with no address');
|
|
37
|
+
ensure(await capped.admit(row(null), limits), 'admit refused a null-caller start below perCaller');
|
|
38
|
+
ensure(!(await capped.admit(row(null), limits)), 'null callers do not share one bucket');
|
|
39
|
+
// admit: a removed row frees its slot.
|
|
40
|
+
const mine = row('c');
|
|
41
|
+
const slots = await create();
|
|
42
|
+
ensure(await slots.admit(mine, { perCaller: 1 }), 'admit refused a first start');
|
|
43
|
+
ensure(!(await slots.admit(row('c'), { perCaller: 1 })), 'admit ignored a cap of 1');
|
|
44
|
+
await slots.transaction((tx) => slots.remove(tx, mine.id));
|
|
45
|
+
ensure(await slots.admit(row('c'), { perCaller: 1 }), 'remove did not free the caller slot');
|
|
46
|
+
// lock: a second caller waits until the first transaction ends.
|
|
47
|
+
const locked = await create();
|
|
48
|
+
const target = row('d');
|
|
49
|
+
ensure(await locked.admit(target, { perCaller: 8 }), 'admit refused a first start');
|
|
50
|
+
const order = [];
|
|
51
|
+
let holding;
|
|
52
|
+
const held = new Promise((resolve) => {
|
|
53
|
+
holding = resolve;
|
|
54
|
+
});
|
|
55
|
+
const first = locked.transaction(async (tx) => {
|
|
56
|
+
ensure((await locked.lock(tx, target.id)) !== undefined, 'lock did not find the row');
|
|
57
|
+
holding();
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
59
|
+
order.push('first ends');
|
|
60
|
+
});
|
|
61
|
+
await held;
|
|
62
|
+
const second = locked.transaction(async (tx) => {
|
|
63
|
+
await locked.lock(tx, target.id);
|
|
64
|
+
order.push('second locked');
|
|
65
|
+
});
|
|
66
|
+
await Promise.all([first, second]);
|
|
67
|
+
ensure(order.join() === 'first ends,second locked', 'lock is not exclusive: a second caller got the row while the first transaction was open');
|
|
68
|
+
// stampPoll: one stamp a gap, answered true then false.
|
|
69
|
+
const polled = await create();
|
|
70
|
+
const stamped = row('e');
|
|
71
|
+
ensure(await polled.admit(stamped, { perCaller: 8 }), 'admit refused a first start');
|
|
72
|
+
const stamps = await polled.transaction(async (tx) => [
|
|
73
|
+
await polled.stampPoll(tx, stamped.id),
|
|
74
|
+
await polled.stampPoll(tx, stamped.id),
|
|
75
|
+
]);
|
|
76
|
+
ensure(stamps[0] === true, 'stampPoll refused the first attempt on a row');
|
|
77
|
+
ensure(stamps[1] === false, 'stampPoll allowed a second attempt inside the gap');
|
|
78
|
+
// patch and read: a row keeps what was written and reads back as its status says.
|
|
79
|
+
const patched = await create();
|
|
80
|
+
const target2 = row('f');
|
|
81
|
+
ensure(await patched.admit(target2, { perCaller: 8 }), 'admit refused a first start');
|
|
82
|
+
const started = await patched.read(target2.id);
|
|
83
|
+
ensure(started?.row.status === 'started' && started.row.payload === null && started.live, 'a new row did not read back as a live started row');
|
|
84
|
+
const revision = '11111111-1111-4111-8111-111111111111';
|
|
85
|
+
await patched.transaction(async (tx) => {
|
|
86
|
+
await patched.patch(tx, target2.id, {
|
|
87
|
+
status: 'completed',
|
|
88
|
+
payload: 'sealed',
|
|
89
|
+
subject: 'user-1',
|
|
90
|
+
email: null,
|
|
91
|
+
revision,
|
|
92
|
+
});
|
|
93
|
+
await patched.patch(tx, target2.id, { status: 'approved' });
|
|
94
|
+
});
|
|
95
|
+
const approved = (await patched.read(target2.id))?.row;
|
|
96
|
+
ensure(approved?.status === 'approved' &&
|
|
97
|
+
approved.payload === 'sealed' &&
|
|
98
|
+
approved.subject === 'user-1' &&
|
|
99
|
+
approved.email === null &&
|
|
100
|
+
approved.revision === revision &&
|
|
101
|
+
approved.challenge === target2.challenge &&
|
|
102
|
+
approved.browserHash === target2.browserHash &&
|
|
103
|
+
approved.stateHash === target2.stateHash, 'patch did not keep what it wrote');
|
|
104
|
+
await patched.transaction((tx) => patched.remove(tx, target2.id));
|
|
105
|
+
ensure((await patched.read(target2.id)) === undefined, 'remove left the row');
|
|
106
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DesktopHandoffStore } from './desktop-store.js';
|
|
2
|
+
/** Runs one statement; `$1`, `$2`... in `text` are `values`. Resolves to the rows, empty for a statement that returns none. */
|
|
3
|
+
export interface DesktopHandoffQuery {
|
|
4
|
+
query(text: string, values: readonly unknown[]): Promise<readonly Record<string, unknown>[]>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* What the app gives `createPostgresDesktopHandoffStore`. `transaction` runs
|
|
8
|
+
* `fn` on one connection inside one transaction: it commits when `fn` returns
|
|
9
|
+
* and rolls back when `fn` throws. The `tx` it hands over runs its statements
|
|
10
|
+
* in that transaction.
|
|
11
|
+
*/
|
|
12
|
+
export interface DesktopHandoffSqlClient extends DesktopHandoffQuery {
|
|
13
|
+
transaction<T>(fn: (tx: DesktopHandoffQuery) => Promise<T>): Promise<T>;
|
|
14
|
+
}
|
|
15
|
+
export interface PostgresDesktopHandoffStoreOptions {
|
|
16
|
+
/** At most this many unexpired rows exist at once; a start beyond it is refused. Defaults to 128. */
|
|
17
|
+
capacity?: number;
|
|
18
|
+
/** How long a row lives after `admit`. Defaults to 5 minutes. */
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
/** The minimum gap `stampPoll` allows between two redeem attempts on the same row. Defaults to 1 second. */
|
|
21
|
+
pollGapMs?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The table, for a new app to run once in its migrations. Boule's and
|
|
25
|
+
* Archon's tables are this table already, except that Archon's lacks
|
|
26
|
+
* `caller_hash` (`ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS
|
|
27
|
+
* caller_hash text`).
|
|
28
|
+
*
|
|
29
|
+
* The CHECK on `payload` keeps a sealed cookie from outliving `completed` and
|
|
30
|
+
* `approved`, so a cancelled, redeemed or abandoned row never holds a
|
|
31
|
+
* credential. The runtime role needs SELECT, INSERT, UPDATE and DELETE.
|
|
32
|
+
*/
|
|
33
|
+
export declare const DESKTOP_HANDOFFS_TABLE_SQL = "CREATE TABLE IF NOT EXISTS desktop_handoffs (\n id uuid PRIMARY KEY,\n challenge text NOT NULL CHECK (length(challenge) = 43),\n browser_hash text,\n state_hash text,\n caller_hash text,\n status text NOT NULL CHECK (status IN ('started', 'completed', 'approved', 'cancelled', 'redeemed')),\n payload text,\n subject text,\n email text,\n revision uuid,\n created_at timestamptz NOT NULL DEFAULT clock_timestamp(),\n expires_at timestamptz NOT NULL DEFAULT clock_timestamp() + interval '5 minutes',\n last_poll timestamptz,\n CONSTRAINT desktop_handoffs_payload_check\n CHECK ((status IN ('completed', 'approved')) = (payload IS NOT NULL))\n);\n\nCREATE INDEX IF NOT EXISTS desktop_handoffs_expires_at_idx ON desktop_handoffs (expires_at);\n";
|
|
34
|
+
/**
|
|
35
|
+
* Prunes, counts and inserts under one advisory lock, so two starts cannot both
|
|
36
|
+
* take the last slot of the total or of a caller's bucket. `lock` is
|
|
37
|
+
* `SELECT ... FOR UPDATE`, held to the end of the transaction; `live` and the
|
|
38
|
+
* poll gap come from the database's clock, read after any wait for a lock.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createPostgresDesktopHandoffStore(client: DesktopHandoffSqlClient, options?: PostgresDesktopHandoffStoreOptions): DesktopHandoffStore;
|