@wtfalch/auth 0.30.0 → 0.31.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 +112 -0
- package/dist/auth.d.ts +29 -1
- package/dist/auth.js +138 -4
- package/dist/broker.d.ts +6 -0
- package/dist/broker.js +5 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +17 -1
- package/dist/config.d.ts +34 -4
- package/dist/config.js +1 -0
- package/dist/desktop.d.ts +199 -0
- package/dist/desktop.js +528 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +15 -4
- package/dist/mcp.js +24 -10
- package/dist/names.d.ts +5 -3
- package/dist/names.js +15 -5
- package/dist/namespace-browser.js +4 -3
- package/dist/namespace-session.js +2 -2
- package/dist/next.js +1 -1
- package/dist/oidc.d.ts +24 -1
- package/dist/oidc.js +57 -1
- package/dist/onboarding.d.ts +0 -2
- package/dist/onboarding.js +0 -2
- package/dist/person-token.d.ts +44 -10
- package/dist/person-token.js +108 -36
- package/dist/qr-door-scene.d.ts +5 -0
- package/dist/qr-door-scene.js +14 -0
- package/dist/qr-door.js +14 -3
- package/dist/redirect.js +4 -3
- package/dist/sessions.d.ts +0 -5
- package/dist/sessions.js +0 -60
- package/dist/util.d.ts +14 -0
- package/dist/util.js +16 -0
- package/dist/worker-session.d.ts +14 -0
- package/dist/worker-session.js +29 -16
- package/package.json +4 -4
- package/dist/testing.d.ts +0 -57
- package/dist/testing.js +0 -138
|
@@ -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
|
+
};
|