@stonyx/oauth 0.1.1-alpha.29 → 0.1.1-alpha.30

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 CHANGED
@@ -47,6 +47,9 @@ By default no providers are enabled. Add providers as keys in the `providers` ob
47
47
  |--------|---------|-------------|
48
48
  | `providers` | `{}` | Map of provider name to config |
49
49
  | `sessionDuration` | `86400` | Session TTL in seconds (default: 24h) |
50
+ | `frontendCallbackUrl` | `null` | Where `GET /auth/callback/:provider` sends the browser after a successful login. **Setting this changes the callback's response shape**: unset, the callback returns `{ sessionId, expiresAt }` as a JSON body; set, it issues a `302` to this URL carrying a single-use exchange ticket in the fragment, which the landing page redeems at `POST /auth/session`. See [Session delivery](#session-delivery--the-exchange-ticket). |
51
+
52
+ `TICKET_TTL_MS` (60s, the exchange ticket's lifetime) and `STATE_TTL_MS` (600s, the `oauth_state` lifetime) are module constants with no config key today. If your landing page cannot reach its earliest hook inside 60 seconds on a cold boot, the exchange returns `400` and the login dies — see [stonyx-oauth#59](https://github.com/abofs/stonyx-oauth/issues/59).
50
53
 
51
54
  ## Routes
52
55
 
@@ -56,7 +59,7 @@ The module self-registers the following routes on the rest server:
56
59
  |--------|-------|-------------|
57
60
  | `GET` | `/auth` | Validate session — send `session-id` header, returns user or 401 |
58
61
  | `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page |
59
- | `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session, redirects with a single-use `ticket` |
62
+ | `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session, redirects with a single-use `ticket` in the URL **fragment** |
60
63
  | `POST` | `/auth/session` | Redeems the `ticket` for the session id — `application/json`, `{ "ticket": "..." }` |
61
64
  | `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
62
65
 
@@ -119,7 +122,7 @@ providers: {
119
122
 
120
123
  ## Login CSRF protection — the `oauth_state` cookie
121
124
 
122
- ### Breaking changes
125
+ ### Breaking changes (#36)
123
126
 
124
127
  **As of the fix for [#36](https://github.com/abofs/stonyx-oauth/issues/36).** Two separate breaks — an integration can hit either one independently.
125
128
 
@@ -177,6 +180,8 @@ await fetch(`${host}/auth/callback/discord?code=${code}&state=${state}`, {
177
180
  });
178
181
  ```
179
182
 
183
+ That callback now answers `302` with an exchange ticket in the `Location` fragment rather than a session id. A scripted client continues by reading the ticket out of the fragment and redeeming it — see [Migration](#migration) under Session delivery for the exchange step. A server-to-server client can do this perfectly well; the "cannot complete a login at all" row in the #45 break table is about clients that cannot issue a cross-origin `POST` from a browser, not about scripted ones.
184
+
180
185
  In a browser, `fetch` needs `credentials: 'include'` for a cross-origin request — but see the CORS caveat above; a top-level navigation is the supported path.
181
186
 
182
187
  ### Concurrent logins in the same browser
@@ -187,30 +192,46 @@ This fails closed — no session is minted for the wrong flow, and it is not a w
187
192
 
188
193
  ## Session delivery — the exchange ticket
189
194
 
190
- ### Breaking changes
195
+ ### Breaking changes (#45)
191
196
 
192
197
  **As of the fix for [#45](https://github.com/abofs/stonyx-oauth/issues/45).** This is a break in the **HTTP contract**, not in the JS API.
193
198
 
194
- `GET /auth/callback/:provider` no longer redirects with `?sessionId=`. It redirects with a single-use, 60-second `?ticket=`, which is exchanged for the session id over a JSON `POST`:
199
+ `GET /auth/callback/:provider` no longer redirects with `?sessionId=`. It redirects with a single-use, 60-second ticket in the URL **fragment**, which is exchanged for the session id over a JSON `POST`:
195
200
 
196
201
  ```
197
- GET /auth/callback/:provider -> 302 <frontendCallbackUrl>?ticket=<opaque>&expiresAt=<ts>
202
+ GET /auth/callback/:provider -> 302 <frontendCallbackUrl>#ticket=<opaque>&expiresAt=<ts>
198
203
  POST /auth/session <- {"ticket":"<opaque>"} Content-Type: application/json
199
204
  -> 200 {"sessionId":"<uuid>","expiresAt":<ts>}
200
205
  -> 400 on an unknown, spent, expired or unparseable ticket
201
206
  ```
202
207
 
208
+ The success redirect carries **no query string at all**. Read the ticket from `location.hash`, not `location.search`. The failure redirect is unchanged and still uses the query (`?error=auth_failed`) — an error code is not a credential.
209
+
203
210
  **Who this breaks, and how:**
204
211
 
205
212
  | Party | What breaks |
206
213
  |---|---|
207
- | **Any client reading `?sessionId=` off the callback redirect** | Gets `undefined`. The redirect no longer carries a session id under any name. |
214
+ | **Any client reading `?sessionId=` off the callback redirect** | Gets `undefined`. The redirect no longer carries a session id under any name, in the query or the fragment. |
215
+ | **Any client reading the callback redirect's query at all** | Gets an empty query on success. Both the ticket and `expiresAt` are in the fragment. A server-side reader **cannot** see either — that is the point, and it is why a browser-side handler is required. |
208
216
  | **Any client that cannot issue a cross-origin `POST`** | Cannot complete a login at all. The exchange is the only way to obtain a session id when `frontendCallbackUrl` is configured. |
209
217
  | **Form-encoded callers** | `@stonyx/rest-server` installs `express.json()` only, so a form-encoded body arrives unparsed and the exchange returns `400`. The request **must** be `application/json`. |
210
218
  | [`abofs/stonyx-dashboard`](https://github.com/abofs/stonyx-dashboard) | `demo-app/routes/auth/discord-callback.js` reads `?sessionId=`. Tracked at [stonyx-dashboard#103](https://github.com/abofs/stonyx-dashboard/issues/103), which must land before that consumer bumps. |
211
219
  | `lynxury/backend` | `test/integration/05-oauth-bypass-test.js` reads `?sessionId=` from the callback redirect. Reds when it bumps off `@stonyx/oauth@0.1.1-beta.157`. |
212
220
  | `lynxury/dashboard` | Bumps its `@stonyx/dashboard` commit pin after #103 lands. |
213
221
 
222
+ ### Deployment prerequisites — the server's own CORS configuration
223
+
224
+ This is the half that breaks on the **server** rather than in the consumer's code, and it fails as a browser console CORS error against a server that logs nothing.
225
+
226
+ Before #45 this module served only `GET`. A deployment that had hardened `REST_CORS_METHODS=GET` was correct and lost nothing. After #45 that same deployment has **no working login at all**: the browser refuses the preflight for `POST /auth/session` and never sends the exchange, and with the session id no longer in the URL there is no fallback path.
227
+
228
+ | Setting | Required value | Why |
229
+ |---|---|---|
230
+ | `REST_CORS_METHODS` | must include `POST` | Default is `GET,POST,PATCH,PUT,DELETE`, which is fine. A narrowed value that omits `POST` kills every login. `@stonyx/rest-server` answers the preflight in middleware before routing, so the server returns `204` either way — the failure is visible only in `Access-Control-Allow-Methods`. |
231
+ | `REST_CORS_ORIGIN` | the frontend origin | Default is `*`. `POST /auth/session` hands out a session id, so under `*` any origin holding a ticket can redeem it and read the result from script. Pin it to the origin serving your `frontendCallbackUrl`. |
232
+
233
+ `test/integration/oauth-test.ts` AC5 asserts both the preflight's `access-control-allow-methods` and the real cross-origin `POST`, so a regression here reds rather than passing silently.
234
+
214
235
  **Unaffected.** The `session-id` **header** contract is unchanged: `GET /auth` and `GET /auth/logout` still authenticate from it, and everything in the [`oauth_state` binding](#login-csrf-protection--the-oauth_state-cookie) is untouched. What changed is how the session id is *delivered once*, not how it is *used afterwards*. The JS API is unchanged — `handleCallback` still returns `{ sessionId, expiresAt }`, and a deployment with **no** `frontendCallbackUrl` configured still gets the session object as the callback's response body, because that is a direct response rather than a value written into a URL.
215
236
 
216
237
  ### Why
@@ -222,13 +243,15 @@ The session id is the bearer credential — `GET /auth` authenticates from exact
222
243
  - proxy, CDN and server access logs,
223
244
  - `location.search`, readable by every script on the landing page.
224
245
 
225
- The first and last of those are the app's own to close and nothing in front of the app can close them: no proxy can unwrite a URL the app chose. `Referrer-Policy` and log scrubbing belong to the frontend and to infrastructure respectively, and this change does not attempt them.
246
+ Putting the ticket in the **fragment** rather than the query removes the middle two outright, for every deployment, with no configuration. A fragment is never transmitted to any server by any user agent: it does not appear in the frontend's own access logs, in any reverse proxy or CDN in front of the landing page, or in `Referer` under any referrer policy.
247
+
248
+ What the fragment does **not** remove is browser history and readability by page scripts (`location.hash` instead of `location.search`). Those are the app's own to close and nothing in front of the app can close them — no proxy can unwrite a URL the app chose. They are why the ticket is still single-use and 60-second rather than a long-lived value, and why the migration below scrubs it with `history.replaceState`.
226
249
 
227
250
  ### Ticket properties
228
251
 
229
252
  | Property | Value | Why |
230
253
  |---|---|---|
231
- | Lifetime | **60 seconds** | One redirect plus one page load. Two orders of magnitude tighter than the 600s state TTL, because unlike the state this value travels in a URL. |
254
+ | Lifetime | **60 seconds** | One redirect plus one page load. Two orders of magnitude tighter than the 600s state TTL, because unlike the state this value travels in a URL — in the fragment, so not to any server, but still into history and into page scripts. |
232
255
  | Uses | **exactly one** | Consumed on recognition, before the TTL is checked, so every ticket gets one attempt whatever the outcome and the route is not a repeatable oracle. |
233
256
  | Entropy | 32 random bytes, base64url | Independent of the session id, never derived from it. |
234
257
  | Authenticates | **nothing** | `GET /auth` validates against the session store, which has never heard of the ticket. A ticket in a `session-id` header is a `401`. |
@@ -238,9 +261,13 @@ The first and last of those are the app's own to close and nothing in front of t
238
261
 
239
262
  **This is a reduction, not an elimination.** A ticket observed in the sub-second window *before* the landing page redeems it is redeemable by the observer. What the change buys is the difference between a live 24-hour credential permanently written into history and a one-shot token that is already spent by the time the page renders.
240
263
 
241
- Closing the window means binding the ticket to the client that started the flow, the way [#36](https://github.com/abofs/stonyx-oauth/issues/36) bound the `state`. That binding has to travel on a cookie, and the exchange is cross-origin, so the cookie cannot be sent without `credentials: 'include'` — which needs [`abofs/stonyx-rest-server#45`](https://github.com/abofs/stonyx-rest-server/issues/45) plus a CORS change, since `origin` defaults to `*` and `*` with credentials is spec-forbidden. **That risk belongs to the rest-server layer.** Revisit when it lands.
264
+ Closing the window means binding the ticket to the client that started the flow, the way [#36](https://github.com/abofs/stonyx-oauth/issues/36) bound the `state`. That binding has to travel on a cookie, and the exchange is cross-origin, so the cookie cannot be sent without `credentials: 'include'`.
265
+
266
+ The blocker is [**`abofs/stonyx-rest-server#63`**](https://github.com/abofs/stonyx-rest-server/issues/63) — `@stonyx/rest-server` calls `cors({ origin, methods })` and has no `credentials` support at all: no `credentials: true`, no `REST_CORS_CREDENTIALS`. A cookie-bound exchange is impossible until that lands, and it will also require pinning `REST_CORS_ORIGIN`, since `*` with credentials is spec-forbidden.
267
+
268
+ It is **not** blocked on [`abofs/stonyx-rest-server#45`](https://github.com/abofs/stonyx-rest-server/issues/45) (*"no supported way for a route handler to set a response header"*). That gap is real but is an ergonomics dependency, and it is already worked around in this very file — `setBindingCookie`/`clearBindingCookie` set and clear cookies on a redirect today by reaching through `req.res`. Closing #45 would not make this residual closeable. **That risk belongs to the rest-server layer.** Revisit when #63 lands.
242
269
 
243
- An abandoned ticket is never garbage-collected, the same pre-existing limitation `pendingStates` has. It is bounded by a 60-second TTL rather than a 600-second one.
270
+ An abandoned ticket is never garbage-collected, the same pre-existing limitation `pendingStates` has. It is bounded by a 60-second TTL rather than a 600-second one. Both maps are tracked at [stonyx-oauth#43](https://github.com/abofs/stonyx-oauth/issues/43), which names each site so a fix cannot sweep one and leave the other.
244
271
 
245
272
  ### Migration
246
273
 
@@ -248,7 +275,9 @@ Read the `ticket`, exchange it, and scrub the URL:
248
275
 
249
276
  ```javascript
250
277
  // On the landing page at your `frontendCallbackUrl`, before first paint.
251
- const params = new URLSearchParams(location.search);
278
+ // The ticket is in the fragment, not the query — `location.hash`, not
279
+ // `location.search`. `.slice(1)` drops the leading `#`.
280
+ const params = new URLSearchParams(location.hash.slice(1));
252
281
  const ticket = params.get('ticket');
253
282
 
254
283
  const response = await fetch(`${host}/auth/session`, {
@@ -262,6 +291,8 @@ if (!response.ok) throw new Error('login failed'); // unknown, spent or expired
262
291
  const { sessionId, expiresAt } = await response.json();
263
292
 
264
293
  // The ticket is spent, but do not leave it in the address bar or in history.
294
+ // The fragment kept it away from every server; `replaceState` is what keeps it
295
+ // out of this browser's history and away from later scripts on the page.
265
296
  history.replaceState({}, '', location.pathname);
266
297
  ```
267
298
 
@@ -4,7 +4,8 @@
4
4
  * Sized for one redirect plus one page load, and deliberately two orders of
5
5
  * magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
6
6
  * travelling in a URL, and the whole point of #45 is that a bearer value in a
7
- * URL must not be long-lived.
7
+ * URL must not be long-lived — in the fragment, so it reaches no server, but
8
+ * still into browser history and readable by scripts on the landing page.
8
9
  */
9
10
  export declare const TICKET_TTL_MS: number;
10
11
  /** Entropy of a ticket, in bytes. */
@@ -22,21 +23,56 @@ export interface RedeemedTicket {
22
23
  * Single-use, short-lived tickets that stand in for a session id on the wire.
23
24
  *
24
25
  * The callback redirect hands the browser a ticket instead of the session id
25
- * (#45). The ticket authenticates nothing `GET /auth` reads the `session-id`
26
- * header and knows only about `SessionManager` so a ticket observed in
27
- * history, in a `Referer`, or by a script reading `location.search` is worth
28
- * something only inside the sub-second window before the landing page redeems
29
- * it, and nothing at all afterwards.
26
+ * (#45), in the URL *fragment*, which no user agent transmits to any server.
27
+ * The ticket authenticates nothing `GET /auth` reads the `session-id` header
28
+ * and knows only about `SessionManager` so a ticket observed in history or
29
+ * by a script reading `location.hash` is worth something only inside the
30
+ * sub-second window before the landing page redeems it, and nothing at all
31
+ * afterwards.
30
32
  *
31
33
  * Known residual, stated rather than papered over: a ticket observed *within*
32
- * that window is redeemable by the observer. Closing it means binding the
33
- * ticket to the client the way #36 bound the state, and that binding has to
34
- * travel on a cookie the cross-origin exchange cannot carry today see
35
- * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
34
+ * that window is redeemable by the observer, because nothing here binds a
35
+ * ticket to the client that started the flow. Closing it means binding the way
36
+ * #36 bound the state, and that binding has to travel on a cookie the
37
+ * cross-origin exchange cannot carry.
38
+ *
39
+ * The blocker is `abofs/stonyx-rest-server#63`: `@stonyx/rest-server` calls
40
+ * `cors({ origin, methods })` and has no `credentials` support at all. It is
41
+ * *not* `abofs/stonyx-rest-server#45` — that issue is the response-header half
42
+ * and is already worked around in `auth-request.ts`, which sets and clears the
43
+ * binding cookie on a redirect by reaching through `req.res`. Closing #45
44
+ * would not make this residual closeable. It is a reduction, not an
45
+ * elimination.
36
46
  *
37
47
  * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
38
48
  * a pre-existing pattern in this module, not something this store introduces,
39
49
  * and it is bounded here by a 60-second TTL rather than a 10-minute one.
50
+ * Tracked, with both maps named, at `abofs/stonyx-oauth#43`.
51
+ *
52
+ * ---
53
+ *
54
+ * **Why this is a second store rather than a reuse of `OAuth.pendingStates`.**
55
+ *
56
+ * The duplication is real and is not an oversight: `pendingStates` is also a
57
+ * single-use, TTL-bounded, consume-on-recognition map keyed by a
58
+ * `randomBytes`-minted opaque token, with the same delete-before-TTL-check
59
+ * ordering and the same never-collected caveat. The shared shape could be
60
+ * extracted into one primitive, and the two constants homes (`STATE_TTL_MS`
61
+ * and `BINDING_VALUE_BYTES` in `main.ts`, `TICKET_TTL_MS` and `TICKET_BYTES`
62
+ * here) could then live together.
63
+ *
64
+ * It is deliberately not done in the change that fixes #45. Widening a
65
+ * security fix into a refactor of the CSRF store means the #36 binding
66
+ * mechanism — whose invariants are load-bearing and separately guarded — moves
67
+ * in the same commit as the fix, for no security gain in either. The two also
68
+ * do not have the same invariants: `pendingStates` is a security control fed
69
+ * by an unauthenticated `GET`, holding a *digest* of a client secret, with a
70
+ * 10-minute budget sized for a provider round trip; this is a delivery
71
+ * convenience reachable only after a successfully bound callback, holding a
72
+ * value it hands back, with a 60-second budget sized for a page load.
73
+ * Collapsing them would couple the control to the convenience.
74
+ *
75
+ * The extraction is tracked at `abofs/stonyx-oauth#58`.
40
76
  */
41
77
  export default class TicketStore {
42
78
  tickets: Map<string, TicketRecord>;
@@ -5,7 +5,8 @@ import { randomBytes } from 'node:crypto';
5
5
  * Sized for one redirect plus one page load, and deliberately two orders of
6
6
  * magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
7
7
  * travelling in a URL, and the whole point of #45 is that a bearer value in a
8
- * URL must not be long-lived.
8
+ * URL must not be long-lived — in the fragment, so it reaches no server, but
9
+ * still into browser history and readable by scripts on the landing page.
9
10
  */
10
11
  export const TICKET_TTL_MS = 60 * 1000;
11
12
  /** Entropy of a ticket, in bytes. */
@@ -14,21 +15,56 @@ export const TICKET_BYTES = 32;
14
15
  * Single-use, short-lived tickets that stand in for a session id on the wire.
15
16
  *
16
17
  * The callback redirect hands the browser a ticket instead of the session id
17
- * (#45). The ticket authenticates nothing `GET /auth` reads the `session-id`
18
- * header and knows only about `SessionManager` so a ticket observed in
19
- * history, in a `Referer`, or by a script reading `location.search` is worth
20
- * something only inside the sub-second window before the landing page redeems
21
- * it, and nothing at all afterwards.
18
+ * (#45), in the URL *fragment*, which no user agent transmits to any server.
19
+ * The ticket authenticates nothing `GET /auth` reads the `session-id` header
20
+ * and knows only about `SessionManager` so a ticket observed in history or
21
+ * by a script reading `location.hash` is worth something only inside the
22
+ * sub-second window before the landing page redeems it, and nothing at all
23
+ * afterwards.
22
24
  *
23
25
  * Known residual, stated rather than papered over: a ticket observed *within*
24
- * that window is redeemable by the observer. Closing it means binding the
25
- * ticket to the client the way #36 bound the state, and that binding has to
26
- * travel on a cookie the cross-origin exchange cannot carry today see
27
- * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
26
+ * that window is redeemable by the observer, because nothing here binds a
27
+ * ticket to the client that started the flow. Closing it means binding the way
28
+ * #36 bound the state, and that binding has to travel on a cookie the
29
+ * cross-origin exchange cannot carry.
30
+ *
31
+ * The blocker is `abofs/stonyx-rest-server#63`: `@stonyx/rest-server` calls
32
+ * `cors({ origin, methods })` and has no `credentials` support at all. It is
33
+ * *not* `abofs/stonyx-rest-server#45` — that issue is the response-header half
34
+ * and is already worked around in `auth-request.ts`, which sets and clears the
35
+ * binding cookie on a redirect by reaching through `req.res`. Closing #45
36
+ * would not make this residual closeable. It is a reduction, not an
37
+ * elimination.
28
38
  *
29
39
  * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
30
40
  * a pre-existing pattern in this module, not something this store introduces,
31
41
  * and it is bounded here by a 60-second TTL rather than a 10-minute one.
42
+ * Tracked, with both maps named, at `abofs/stonyx-oauth#43`.
43
+ *
44
+ * ---
45
+ *
46
+ * **Why this is a second store rather than a reuse of `OAuth.pendingStates`.**
47
+ *
48
+ * The duplication is real and is not an oversight: `pendingStates` is also a
49
+ * single-use, TTL-bounded, consume-on-recognition map keyed by a
50
+ * `randomBytes`-minted opaque token, with the same delete-before-TTL-check
51
+ * ordering and the same never-collected caveat. The shared shape could be
52
+ * extracted into one primitive, and the two constants homes (`STATE_TTL_MS`
53
+ * and `BINDING_VALUE_BYTES` in `main.ts`, `TICKET_TTL_MS` and `TICKET_BYTES`
54
+ * here) could then live together.
55
+ *
56
+ * It is deliberately not done in the change that fixes #45. Widening a
57
+ * security fix into a refactor of the CSRF store means the #36 binding
58
+ * mechanism — whose invariants are load-bearing and separately guarded — moves
59
+ * in the same commit as the fix, for no security gain in either. The two also
60
+ * do not have the same invariants: `pendingStates` is a security control fed
61
+ * by an unauthenticated `GET`, holding a *digest* of a client secret, with a
62
+ * 10-minute budget sized for a provider round trip; this is a delivery
63
+ * convenience reachable only after a successfully bound callback, holding a
64
+ * value it hands back, with a 60-second budget sized for a page load.
65
+ * Collapsing them would couple the control to the convenience.
66
+ *
67
+ * The extraction is tracked at `abofs/stonyx-oauth#58`.
32
68
  */
33
69
  export default class TicketStore {
34
70
  tickets = new Map();
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.29",
7
+ "version": "0.1.1-alpha.30",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -6,7 +6,8 @@ import { randomBytes } from 'node:crypto';
6
6
  * Sized for one redirect plus one page load, and deliberately two orders of
7
7
  * magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
8
8
  * travelling in a URL, and the whole point of #45 is that a bearer value in a
9
- * URL must not be long-lived.
9
+ * URL must not be long-lived — in the fragment, so it reaches no server, but
10
+ * still into browser history and readable by scripts on the landing page.
10
11
  */
11
12
  export const TICKET_TTL_MS = 60 * 1000;
12
13
 
@@ -28,21 +29,56 @@ export interface RedeemedTicket {
28
29
  * Single-use, short-lived tickets that stand in for a session id on the wire.
29
30
  *
30
31
  * The callback redirect hands the browser a ticket instead of the session id
31
- * (#45). The ticket authenticates nothing `GET /auth` reads the `session-id`
32
- * header and knows only about `SessionManager` so a ticket observed in
33
- * history, in a `Referer`, or by a script reading `location.search` is worth
34
- * something only inside the sub-second window before the landing page redeems
35
- * it, and nothing at all afterwards.
32
+ * (#45), in the URL *fragment*, which no user agent transmits to any server.
33
+ * The ticket authenticates nothing `GET /auth` reads the `session-id` header
34
+ * and knows only about `SessionManager` so a ticket observed in history or
35
+ * by a script reading `location.hash` is worth something only inside the
36
+ * sub-second window before the landing page redeems it, and nothing at all
37
+ * afterwards.
36
38
  *
37
39
  * Known residual, stated rather than papered over: a ticket observed *within*
38
- * that window is redeemable by the observer. Closing it means binding the
39
- * ticket to the client the way #36 bound the state, and that binding has to
40
- * travel on a cookie the cross-origin exchange cannot carry today see
41
- * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
40
+ * that window is redeemable by the observer, because nothing here binds a
41
+ * ticket to the client that started the flow. Closing it means binding the way
42
+ * #36 bound the state, and that binding has to travel on a cookie the
43
+ * cross-origin exchange cannot carry.
44
+ *
45
+ * The blocker is `abofs/stonyx-rest-server#63`: `@stonyx/rest-server` calls
46
+ * `cors({ origin, methods })` and has no `credentials` support at all. It is
47
+ * *not* `abofs/stonyx-rest-server#45` — that issue is the response-header half
48
+ * and is already worked around in `auth-request.ts`, which sets and clears the
49
+ * binding cookie on a redirect by reaching through `req.res`. Closing #45
50
+ * would not make this residual closeable. It is a reduction, not an
51
+ * elimination.
42
52
  *
43
53
  * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
44
54
  * a pre-existing pattern in this module, not something this store introduces,
45
55
  * and it is bounded here by a 60-second TTL rather than a 10-minute one.
56
+ * Tracked, with both maps named, at `abofs/stonyx-oauth#43`.
57
+ *
58
+ * ---
59
+ *
60
+ * **Why this is a second store rather than a reuse of `OAuth.pendingStates`.**
61
+ *
62
+ * The duplication is real and is not an oversight: `pendingStates` is also a
63
+ * single-use, TTL-bounded, consume-on-recognition map keyed by a
64
+ * `randomBytes`-minted opaque token, with the same delete-before-TTL-check
65
+ * ordering and the same never-collected caveat. The shared shape could be
66
+ * extracted into one primitive, and the two constants homes (`STATE_TTL_MS`
67
+ * and `BINDING_VALUE_BYTES` in `main.ts`, `TICKET_TTL_MS` and `TICKET_BYTES`
68
+ * here) could then live together.
69
+ *
70
+ * It is deliberately not done in the change that fixes #45. Widening a
71
+ * security fix into a refactor of the CSRF store means the #36 binding
72
+ * mechanism — whose invariants are load-bearing and separately guarded — moves
73
+ * in the same commit as the fix, for no security gain in either. The two also
74
+ * do not have the same invariants: `pendingStates` is a security control fed
75
+ * by an unauthenticated `GET`, holding a *digest* of a client secret, with a
76
+ * 10-minute budget sized for a provider round trip; this is a delivery
77
+ * convenience reachable only after a successfully bound callback, holding a
78
+ * value it hands back, with a 60-second budget sized for a page load.
79
+ * Collapsing them would couple the control to the convenience.
80
+ *
81
+ * The extraction is tracked at `abofs/stonyx-oauth#58`.
46
82
  */
47
83
  export default class TicketStore {
48
84
  tickets = new Map<string, TicketRecord>();