@wtfalch/auth 0.32.0 → 0.34.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.
@@ -0,0 +1,111 @@
1
+ /*
2
+ * The handoff's row store: the interface a store implements over a table, and a
3
+ * memory implementation for one process. `createPostgresDesktopHandoffStore`
4
+ * (desktop-store-postgres.ts) is the shared Postgres one.
5
+ * `checkDesktopHandoffStore` (desktop-store-contract.ts) runs the contract below
6
+ * against any store.
7
+ */
8
+ /**
9
+ * One process only. For tests and a single-instance development server: two
10
+ * replicas with separate memory stores do not share rows.
11
+ */
12
+ export function createMemoryDesktopHandoffStore(options) {
13
+ const capacity = options?.capacity ?? 128;
14
+ const ttlMs = options?.ttlMs ?? 5 * 60 * 1000;
15
+ const pollGapMs = options?.pollGapMs ?? 1000;
16
+ const rows = new Map();
17
+ /** Per row id, the end of the line of transactions waiting for it. */
18
+ const lineEnds = new Map();
19
+ async function acquire(tx, id) {
20
+ if (tx.held.has(id))
21
+ return;
22
+ const before = lineEnds.get(id) ?? Promise.resolve();
23
+ let release;
24
+ const turn = new Promise((resolve) => {
25
+ release = resolve;
26
+ });
27
+ const end = before.then(() => turn);
28
+ lineEnds.set(id, end);
29
+ await before;
30
+ tx.held.set(id, () => {
31
+ if (lineEnds.get(id) === end)
32
+ lineEnds.delete(id);
33
+ release();
34
+ });
35
+ }
36
+ const sweep = () => {
37
+ const now = Date.now();
38
+ for (const [id, entry] of rows)
39
+ if (entry.expiresAt <= now)
40
+ rows.delete(id);
41
+ };
42
+ const found = (entry) => {
43
+ const { expiresAt, lastPoll: _poll, callerHash: _caller, ...row } = entry;
44
+ return { row, live: expiresAt > Date.now() };
45
+ };
46
+ return {
47
+ async admit(row, limits) {
48
+ sweep();
49
+ if (rows.size >= capacity)
50
+ return false;
51
+ let mine = 0;
52
+ for (const entry of rows.values())
53
+ if (entry.callerHash === row.callerHash)
54
+ mine++;
55
+ if (mine >= limits.perCaller)
56
+ return false;
57
+ rows.set(row.id, {
58
+ id: row.id,
59
+ status: 'started',
60
+ challenge: row.challenge,
61
+ browserHash: row.browserHash,
62
+ stateHash: row.stateHash,
63
+ payload: null,
64
+ subject: null,
65
+ email: null,
66
+ revision: null,
67
+ expiresAt: Date.now() + ttlMs,
68
+ lastPoll: null,
69
+ callerHash: row.callerHash,
70
+ });
71
+ return true;
72
+ },
73
+ async transaction(fn) {
74
+ const tx = { held: new Map() };
75
+ try {
76
+ return await fn(tx);
77
+ }
78
+ finally {
79
+ for (const release of tx.held.values())
80
+ release();
81
+ }
82
+ },
83
+ async lock(tx, id) {
84
+ await acquire(tx, id);
85
+ const entry = rows.get(id);
86
+ return entry ? found(entry) : undefined;
87
+ },
88
+ async read(id) {
89
+ const entry = rows.get(id);
90
+ return entry ? found(entry) : undefined;
91
+ },
92
+ async patch(_tx, id, set) {
93
+ const entry = rows.get(id);
94
+ if (entry)
95
+ Object.assign(entry, set);
96
+ },
97
+ async remove(_tx, id) {
98
+ rows.delete(id);
99
+ },
100
+ async stampPoll(_tx, id) {
101
+ const entry = rows.get(id);
102
+ if (!entry)
103
+ return false;
104
+ const now = Date.now();
105
+ if (entry.lastPoll !== null && entry.lastPoll >= now - pollGapMs)
106
+ return false;
107
+ entry.lastPoll = now;
108
+ return true;
109
+ },
110
+ };
111
+ }
package/dist/desktop.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { Auth, User } from './auth.js';
2
+ import type { DesktopHandoffStatus, DesktopHandoffStore } from './desktop-store.js';
3
+ export { type DesktopHandoffFound, type DesktopHandoffPatch, type DesktopHandoffRow, type DesktopHandoffStatus, type DesktopHandoffStore, type DesktopHandoffTx, createMemoryDesktopHandoffStore, } from './desktop-store.js';
4
+ export { DESKTOP_HANDOFFS_TABLE_SQL, type DesktopHandoffQuery, type DesktopHandoffSqlClient, type PostgresDesktopHandoffStoreOptions, createPostgresDesktopHandoffStore, } from './desktop-store-postgres.js';
5
+ export { checkDesktopHandoffStore } from './desktop-store-contract.js';
2
6
  /**
3
7
  * The native sign-in handoff for a desktop or mobile shell, mounted under
4
8
  * `/auth/desktop/*`. Merged from Boule's and Archon's own copies
@@ -29,8 +33,9 @@ import type { Auth, User } from './auth.js';
29
33
  *
30
34
  * The store is the app's: this package never connects to a database it does
31
35
  * not own. `createMemoryDesktopHandoffStore` is enough for a single-instance
32
- * server and for tests; a multi-replica app supplies its own, backed by
33
- * whatever table it migrates.
36
+ * server and for tests; a multi-replica app passes its Postgres client to
37
+ * `createPostgresDesktopHandoffStore` over the table it migrates
38
+ * (`DESKTOP_HANDOFFS_TABLE_SQL`).
34
39
  */
35
40
  export type DesktopHandoffAuth = Pick<Auth, 'sessionCookieName' | 'start' | 'complete' | 'readCookie'>;
36
41
  export declare class DesktopHandoffError extends Error {
@@ -38,75 +43,6 @@ export declare class DesktopHandoffError extends Error {
38
43
  readonly code: string;
39
44
  constructor(status: number, code: string);
40
45
  }
41
- export type DesktopHandoffStatus = 'started' | 'completed' | 'approved';
42
- export interface DesktopHandoffRow {
43
- id: string;
44
- status: DesktopHandoffStatus;
45
- challenge: string;
46
- browserHash: string | null;
47
- stateHash: string | null;
48
- payload: string | null;
49
- subject: string | null;
50
- email: string | null;
51
- revision: string | null;
52
- }
53
- export interface DesktopHandoffFound {
54
- row: DesktopHandoffRow;
55
- /** The store's own clock decides this, read after any wait for a lock. */
56
- live: boolean;
57
- /** Whether a redeem attempt may be stamped now: at most one a second per row. */
58
- pollOk: boolean;
59
- }
60
- /**
61
- * The handoff's row store. The app's own, not this package's: an app
62
- * migrates one table and implements this interface over it.
63
- *
64
- * A real store must make `lock` exclusive: inside `transaction`, `lock` is
65
- * `SELECT ... FOR UPDATE` on the row, held until the transaction ends. The
66
- * handler's one-use guarantee (a redeem returns the cookie once, a callback
67
- * admits once) rests on two concurrent callers on one row taking turns. A store
68
- * whose `lock` is a plain read lets both pass, and both get the cookie.
69
- */
70
- export interface DesktopHandoffStore<Tx = unknown> {
71
- /**
72
- * Prunes expired rows, then inserts a `started` row if there is room: fewer
73
- * than `perCaller` unexpired rows with the same `callerHash`, and the store's
74
- * own total below its capacity. False when either limit is reached.
75
- * `callerHash` is a digest of the caller's address, null when the request
76
- * carried none; null callers share one bucket.
77
- */
78
- admit(row: {
79
- id: string;
80
- challenge: string;
81
- browserHash: string;
82
- stateHash: string;
83
- callerHash: string | null;
84
- }, limits: {
85
- perCaller: number;
86
- }): Promise<boolean>;
87
- transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
88
- /** The row, locked until the transaction ends (`SELECT ... FOR UPDATE`). A second caller waits for the first to finish. */
89
- lock(tx: Tx, id: string): Promise<DesktopHandoffFound | undefined>;
90
- /** The row without a lock, for a page that only shows it. */
91
- read(id: string): Promise<DesktopHandoffFound | undefined>;
92
- patch(tx: Tx, id: string, set: Partial<DesktopHandoffRow>): Promise<void>;
93
- /** Deletes the row: a sign-in that ended holds no payload, email or subject, and frees its slot. */
94
- remove(tx: Tx, id: string): Promise<void>;
95
- /** Stamps a redeem attempt, which `pollOk` reads. */
96
- polled(tx: Tx, id: string): Promise<void>;
97
- }
98
- /**
99
- * One process only. For tests and a single-instance development server: two
100
- * replicas with separate memory stores do not share rows.
101
- */
102
- export declare function createMemoryDesktopHandoffStore(options?: {
103
- /** At most this many unexpired rows exist at once; a start beyond it is refused. Defaults to 128. */
104
- capacity?: number;
105
- /** How long a row lives after `admit`. Defaults to 5 minutes. */
106
- ttlMs?: number;
107
- /** The minimum gap `pollOk` allows between two redeem attempts on the same row. Defaults to 1 second. */
108
- pollGapMs?: number;
109
- }): DesktopHandoffStore;
110
46
  /**
111
47
  * What the admission hook answers before a session is handed to the shell.
112
48
  * Boule's and Archon's copies differed here: Boule handed over whoever the
@@ -139,7 +75,7 @@ export interface DesktopHandoffCurrent {
139
75
  */
140
76
  returnUrl: string | null;
141
77
  }
142
- export interface DesktopHandoffConfig<Tx = unknown> {
78
+ export interface DesktopHandoffConfig {
143
79
  /** The SDK instance the browser side signs in with: a second `createAuth`, its `basePath` moved to `/auth/desktop`, registered at the issuer next to the app's own callback. */
144
80
  auth: DesktopHandoffAuth;
145
81
  /** The app's own origin. */
@@ -150,7 +86,7 @@ export interface DesktopHandoffConfig<Tx = unknown> {
150
86
  appId: string;
151
87
  /** The shell's own return scheme (`cookieHandoff({ scheme })` in `@wtfalch/desktop`, or a mobile shell's own); lowercase letters, digits, `+`, `.` and `-`, starting with a letter. The app's config, never the request, decides this. */
152
88
  scheme: string;
153
- store: DesktopHandoffStore<Tx>;
89
+ store: DesktopHandoffStore;
154
90
  /**
155
91
  * Defaults to `alwaysAdmit`, which admits whoever the issuer signed in,
156
92
  * unchanged (Boule's own behaviour). That is fail-open: an app that needs an
@@ -175,7 +111,7 @@ export interface DesktopHandoffErrorEvent {
175
111
  reason: string;
176
112
  errorName: string;
177
113
  }
178
- export declare function createDesktopHandoff<Tx = unknown>({ auth, appUrl, secret, appId, scheme, store, admit, onError, clientAddressHeader, maxStartsPerCaller, }: DesktopHandoffConfig<Tx>): {
114
+ export declare function createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit, onError, clientAddressHeader, maxStartsPerCaller, }: DesktopHandoffConfig): {
179
115
  /** The cookie name, for the one place a test or a route needs to read it. */
180
116
  cookieName: string;
181
117
  /** GET /auth/desktop/start */
@@ -194,6 +130,5 @@ export declare function createDesktopHandoff<Tx = unknown>({ auth, appUrl, secre
194
130
  cancel: (request: Request) => Promise<Response>;
195
131
  /** POST /auth/desktop/redeem, with the body still unread: refuses one that is not small and declared. */
196
132
  redeemRequest(request: Request): Promise<Response>;
197
- /** The shell trades its verifier for the cookie, once. No browser cookie involved. */
198
- redeem(input: unknown): Promise<Response>;
133
+ redeem: (input: unknown) => Promise<Response>;
199
134
  };