@wtfalch/auth 0.30.0 → 0.32.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,199 @@
1
+ import type { Auth, User } from './auth.js';
2
+ /**
3
+ * The native sign-in handoff for a desktop or mobile shell, mounted under
4
+ * `/auth/desktop/*`. Merged from Boule's and Archon's own copies
5
+ * (`src/lib/desktop-handoff`), which a shell (`@wtfalch/desktop`'s
6
+ * `cookieHandoff` strategy, or a mobile shell of its own) talks to the same
7
+ * way regardless of which app it signs into.
8
+ *
9
+ * The shell opens `/auth/desktop/start` in the system browser; the issuer
10
+ * signs the person in; the person confirms in the browser; the shell trades
11
+ * its verifier for the session cookie, once, at `/auth/desktop/redeem`.
12
+ *
13
+ * A row carries the cookie from the callback to the redeem, sealed, and never
14
+ * reaches the browser: the callback sets no session cookie, so the browser's
15
+ * own session in the app is untouched.
16
+ *
17
+ * started /start ran; waiting for the issuer's callback
18
+ * completed the callback verified the person; the cookie is sealed in the row
19
+ * approved the person confirmed in the browser that started it
20
+ *
21
+ * A sign-in that ends (redeemed, cancelled, expired when asked about) has its
22
+ * row deleted, so the store never represents those as a status. A failed
23
+ * callback deletes nothing: a replayed callback fails at the issuer while the
24
+ * first one may still be succeeding, so the row just expires.
25
+ *
26
+ * Every state-changing step is one transaction on a locked row, and the
27
+ * store's own clock decides expiry. Nothing here logs a cookie, a verifier, a
28
+ * nonce or a payload.
29
+ *
30
+ * The store is the app's: this package never connects to a database it does
31
+ * 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.
34
+ */
35
+ export type DesktopHandoffAuth = Pick<Auth, 'sessionCookieName' | 'start' | 'complete' | 'readCookie'>;
36
+ export declare class DesktopHandoffError extends Error {
37
+ readonly status: number;
38
+ readonly code: string;
39
+ constructor(status: number, code: string);
40
+ }
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
+ /**
111
+ * What the admission hook answers before a session is handed to the shell.
112
+ * Boule's and Archon's copies differed here: Boule handed over whoever the
113
+ * issuer signed in, unchanged; Archon asked a further question first --
114
+ * organisation membership, a second factor, a link to another principal --
115
+ * and refused otherwise. Archon's is the stricter of the two, so it is the
116
+ * one this handler keeps a hook for; the default (`alwaysAdmit`) reproduces
117
+ * Boule's own behaviour for an app that has nothing further to check.
118
+ */
119
+ export type DesktopHandoffAdmit = (user: User) => Promise<{
120
+ ok: true;
121
+ user: {
122
+ id: string;
123
+ };
124
+ } | {
125
+ ok: false;
126
+ reason: string;
127
+ }>;
128
+ export interface DesktopHandoffCurrent {
129
+ id: string;
130
+ status: DesktopHandoffStatus | 'gone';
131
+ email: string | null;
132
+ revision: string | null;
133
+ code: string | null;
134
+ /**
135
+ * `${scheme}://auth/done?id=<id>`, once the sign-in is approved; null
136
+ * otherwise. Built only from this handler's own configured `scheme` --
137
+ * never from the request -- so a shell can only ever be handed back its
138
+ * own scheme.
139
+ */
140
+ returnUrl: string | null;
141
+ }
142
+ export interface DesktopHandoffConfig<Tx = unknown> {
143
+ /** 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
+ auth: DesktopHandoffAuth;
145
+ /** The app's own origin. */
146
+ appUrl: string;
147
+ /** The app's cookie secret. The handoff payload is sealed with a key derived from it, so no secret of its own is needed. */
148
+ secret: string;
149
+ /** Names this app in the browser-binding cookie (`<appId>_desktop`); lowercase letters, digits and hyphens, starting with a letter. */
150
+ appId: string;
151
+ /** 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
+ scheme: string;
153
+ store: DesktopHandoffStore<Tx>;
154
+ /**
155
+ * Defaults to `alwaysAdmit`, which admits whoever the issuer signed in,
156
+ * unchanged (Boule's own behaviour). That is fail-open: an app that needs an
157
+ * organisation, second-factor or linked-principal check and leaves this out
158
+ * is not warned by the type system. Pass the check explicitly.
159
+ */
160
+ admit?: DesktopHandoffAdmit;
161
+ /**
162
+ * Called when a step fails for a reason the handler did not expect (a store or
163
+ * issuer outage, a bug), where the browser sees only `unavailable`. It gets a
164
+ * fixed step name, the reason shown to the caller and the error's class name:
165
+ * never the error message, a cookie, a verifier or a payload. Log it and alert on it.
166
+ */
167
+ onError?: (event: DesktopHandoffErrorEvent) => void;
168
+ /** The request header that carries the caller's address, set by the trusted ingress. Defaults to `cf-connecting-ip`. */
169
+ clientAddressHeader?: string;
170
+ /** At most this many open sign-ins per caller address; the store's capacity stays the ceiling. Defaults to 8. */
171
+ maxStartsPerCaller?: number;
172
+ }
173
+ export interface DesktopHandoffErrorEvent {
174
+ step: 'start' | 'callback' | 'approve' | 'cancel' | 'redeem';
175
+ reason: string;
176
+ errorName: string;
177
+ }
178
+ export declare function createDesktopHandoff<Tx = unknown>({ auth, appUrl, secret, appId, scheme, store, admit, onError, clientAddressHeader, maxStartsPerCaller, }: DesktopHandoffConfig<Tx>): {
179
+ /** The cookie name, for the one place a test or a route needs to read it. */
180
+ cookieName: string;
181
+ /** GET /auth/desktop/start */
182
+ start: (request: Request) => Promise<Response>;
183
+ /** GET /auth/desktop/callback: where the issuer sends the browser. Sets no session cookie. */
184
+ callback: (request: Request) => Promise<Response>;
185
+ /**
186
+ * What the confirm and done pages show, for the browser that holds the cookie; null for any
187
+ * other. A row that is gone (redeemed or cancelled rows are deleted) answers `gone` to the
188
+ * browser that held its binding, which is how the done page still says the app is signed in.
189
+ */
190
+ current(cookieHeader: string | null): Promise<DesktopHandoffCurrent | null>;
191
+ /** POST /auth/desktop/approve: the person confirms the account the page showed. */
192
+ approve: (request: Request) => Promise<Response>;
193
+ /** POST /auth/desktop/cancel: ends the sign-in and deletes its row. */
194
+ cancel: (request: Request) => Promise<Response>;
195
+ /** POST /auth/desktop/redeem, with the body still unread: refuses one that is not small and declared. */
196
+ 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>;
199
+ };