@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 +42 -11
- package/dist/ticket-store.d.ts +46 -10
- package/dist/ticket-store.js +46 -10
- package/package.json +1 -1
- package/src/ticket-store.ts +46 -10
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
|
|
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
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
package/dist/ticket-store.d.ts
CHANGED
|
@@ -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)
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* it, and nothing at all
|
|
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
|
|
33
|
-
* ticket to the client
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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>;
|
package/dist/ticket-store.js
CHANGED
|
@@ -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)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* it, and nothing at all
|
|
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
|
|
25
|
-
* ticket to the client
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
package/src/ticket-store.ts
CHANGED
|
@@ -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)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* it, and nothing at all
|
|
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
|
|
39
|
-
* ticket to the client
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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>();
|