@stonyx/oauth 0.1.1-beta.185 → 0.1.1-beta.187
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 +122 -3
- package/dist/auth-request.d.ts +50 -0
- package/dist/auth-request.js +63 -3
- package/dist/main.d.ts +16 -1
- package/dist/main.js +17 -0
- package/dist/ticket-store.d.ts +134 -0
- package/dist/ticket-store.js +140 -0
- package/package.json +2 -2
- package/src/auth-request.ts +80 -3
- package/src/main.ts +21 -0
- package/src/ticket-store.ts +158 -0
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,8 @@ 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 |
|
|
62
|
+
| `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session, redirects with a single-use `ticket` in the URL **fragment** |
|
|
63
|
+
| `POST` | `/auth/session` | Redeems the `ticket` for the session id — `application/json`, `{ "ticket": "..." }` |
|
|
60
64
|
| `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
|
|
61
65
|
|
|
62
66
|
## Officially Supported Providers
|
|
@@ -118,7 +122,7 @@ providers: {
|
|
|
118
122
|
|
|
119
123
|
## Login CSRF protection — the `oauth_state` cookie
|
|
120
124
|
|
|
121
|
-
### Breaking changes
|
|
125
|
+
### Breaking changes (#36)
|
|
122
126
|
|
|
123
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.
|
|
124
128
|
|
|
@@ -176,6 +180,8 @@ await fetch(`${host}/auth/callback/discord?code=${code}&state=${state}`, {
|
|
|
176
180
|
});
|
|
177
181
|
```
|
|
178
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
|
+
|
|
179
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.
|
|
180
186
|
|
|
181
187
|
### Concurrent logins in the same browser
|
|
@@ -184,11 +190,124 @@ The cookie name is fixed and its `Path` is `/`, so a second login started in the
|
|
|
184
190
|
|
|
185
191
|
This fails closed — no session is minted for the wrong flow, and it is not a way past the binding — but it is an availability regression against the previous behaviour, where two concurrent logins both completed. A user who opens two login tabs has to finish in the one they started last, or retry.
|
|
186
192
|
|
|
193
|
+
## Session delivery — the exchange ticket
|
|
194
|
+
|
|
195
|
+
### Breaking changes (#45)
|
|
196
|
+
|
|
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.
|
|
198
|
+
|
|
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`:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
GET /auth/callback/:provider -> 302 <frontendCallbackUrl>#ticket=<opaque>&expiresAt=<ts>
|
|
203
|
+
POST /auth/session <- {"ticket":"<opaque>"} Content-Type: application/json
|
|
204
|
+
-> 200 {"sessionId":"<uuid>","expiresAt":<ts>}
|
|
205
|
+
Cache-Control: no-store
|
|
206
|
+
-> 400 on an unknown, spent, expired or unparseable ticket
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
**Who this breaks, and how:**
|
|
212
|
+
|
|
213
|
+
| Party | What breaks |
|
|
214
|
+
|---|---|
|
|
215
|
+
| **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. |
|
|
216
|
+
| **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. |
|
|
217
|
+
| **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. |
|
|
218
|
+
| **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`. |
|
|
219
|
+
| [`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. |
|
|
220
|
+
| `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`. |
|
|
221
|
+
| `lynxury/dashboard` | Bumps its `@stonyx/dashboard` commit pin after #103 lands. |
|
|
222
|
+
|
|
223
|
+
### Deployment prerequisites — the server's own CORS configuration
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
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.
|
|
228
|
+
|
|
229
|
+
| Setting | Required value | Why |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `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`. |
|
|
232
|
+
| `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`. |
|
|
233
|
+
|
|
234
|
+
`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.
|
|
235
|
+
|
|
236
|
+
**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.
|
|
237
|
+
|
|
238
|
+
### Why
|
|
239
|
+
|
|
240
|
+
The session id is the bearer credential — `GET /auth` authenticates from exactly that value. Delivering it as a query parameter wrote a live 24-hour credential into:
|
|
241
|
+
|
|
242
|
+
- browser history and the address bar,
|
|
243
|
+
- the `Referer` header on any outbound link from the landing page,
|
|
244
|
+
- proxy, CDN and server access logs,
|
|
245
|
+
- `location.search`, readable by every script on the landing page.
|
|
246
|
+
|
|
247
|
+
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.
|
|
248
|
+
|
|
249
|
+
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`.
|
|
250
|
+
|
|
251
|
+
### Ticket properties
|
|
252
|
+
|
|
253
|
+
| Property | Value | Why |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| 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. |
|
|
256
|
+
| 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. |
|
|
257
|
+
| Entropy | 32 random bytes, base64url | Independent of the session id, never derived from it. |
|
|
258
|
+
| 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`. |
|
|
259
|
+
| Failure modes | one indistinguishable `400` | Unknown, spent, expired and unparseable are not told apart. |
|
|
260
|
+
| Server-side storage | **keyed by the ticket digest** | The store is keyed by the SHA-256 of the ticket, never by the ticket, so the map holds no redeemable *ticket*: a reader of the map gets a digest, and a digest cannot be presented to the exchange. **It does still hold the live `sessionId` in plaintext, in the record value**, so the map is sensitive and must not be dumped or logged. Note this is the mirror image of the `oauth_state` binding rather than the same shape: `pendingStates` is keyed by the plaintext state and keeps the digest (`bindingHash`) in the value, so that record unlocks nothing on its own; here the digest is the key and the value is a live credential. Both share the discipline of never storing the client-presented secret in the clear. No constant-time compare is needed: lookup is a hash probe on a 256-bit key, not a secret-dependent byte comparison. |
|
|
261
|
+
| Exchange response | `Cache-Control: no-store` | The `200` body is the session id. A `POST` is not cacheable without explicit freshness, so this is defence in depth — no intermediary or service worker retains the credential. |
|
|
262
|
+
|
|
263
|
+
### Known residual risk
|
|
264
|
+
|
|
265
|
+
**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.
|
|
266
|
+
|
|
267
|
+
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'`.
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
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.
|
|
272
|
+
|
|
273
|
+
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.
|
|
274
|
+
|
|
275
|
+
### Migration
|
|
276
|
+
|
|
277
|
+
Read the `ticket`, exchange it, and scrub the URL:
|
|
278
|
+
|
|
279
|
+
```javascript
|
|
280
|
+
// On the landing page at your `frontendCallbackUrl`, before first paint.
|
|
281
|
+
// The ticket is in the fragment, not the query — `location.hash`, not
|
|
282
|
+
// `location.search`. `.slice(1)` drops the leading `#`.
|
|
283
|
+
const params = new URLSearchParams(location.hash.slice(1));
|
|
284
|
+
const ticket = params.get('ticket');
|
|
285
|
+
|
|
286
|
+
const response = await fetch(`${host}/auth/session`, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'Content-Type': 'application/json' }, // form-encoded will 400
|
|
289
|
+
body: JSON.stringify({ ticket }),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (!response.ok) throw new Error('login failed'); // unknown, spent or expired
|
|
293
|
+
|
|
294
|
+
const { sessionId, expiresAt } = await response.json();
|
|
295
|
+
|
|
296
|
+
// The ticket is spent, but do not leave it in the address bar or in history.
|
|
297
|
+
// The fragment kept it away from every server; `replaceState` is what keeps it
|
|
298
|
+
// out of this browser's history and away from later scripts on the page.
|
|
299
|
+
history.replaceState({}, '', location.pathname);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Then send `sessionId` as a `session-id` header exactly as before.
|
|
303
|
+
|
|
304
|
+
Exchange promptly — the ticket is valid for 60 seconds. Do it in the earliest hook your framework offers (`beforeModel` in Ember, a loader in Remix or React Router), not after the page has rendered.
|
|
305
|
+
|
|
187
306
|
## Session Management
|
|
188
307
|
|
|
189
308
|
Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
|
|
190
309
|
|
|
191
|
-
Clients
|
|
310
|
+
Clients obtain the `sessionId` by redeeming the callback's exchange ticket at `POST /auth/session` — see [Session delivery](#session-delivery--the-exchange-ticket) — and send it as a `session-id` header on subsequent requests. It is never delivered in a URL.
|
|
192
311
|
|
|
193
312
|
## License
|
|
194
313
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ interface OAuthInstance {
|
|
|
15
15
|
sessionId: string;
|
|
16
16
|
expiresAt: number;
|
|
17
17
|
}>;
|
|
18
|
+
issueExchangeTicket(session: {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}): string;
|
|
22
|
+
redeemExchangeTicket(ticket: string): {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
} | null;
|
|
18
26
|
logout(sessionId: string): void;
|
|
19
27
|
}
|
|
20
28
|
export interface CookieOptions {
|
|
@@ -40,11 +48,25 @@ export interface CookieOptions {
|
|
|
40
48
|
interface ResponseLike {
|
|
41
49
|
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
42
50
|
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
51
|
+
/**
|
|
52
|
+
* Optional: every call site guards on it. `@stonyx/rest-server` hands the
|
|
53
|
+
* express response through untyped, and a test double need not implement the
|
|
54
|
+
* whole surface.
|
|
55
|
+
*/
|
|
56
|
+
setHeader?(name: string, value: string): unknown;
|
|
43
57
|
}
|
|
44
58
|
interface RouteRequest {
|
|
45
59
|
headers: Record<string, string | undefined>;
|
|
46
60
|
params: Record<string, string>;
|
|
47
61
|
query: Record<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
|
|
64
|
+
*
|
|
65
|
+
* Optional and typed loosely because it is whatever an unauthenticated
|
|
66
|
+
* caller sent: a form-encoded body arrives as `null` and a bodyless request
|
|
67
|
+
* as `undefined`, so every read of it has to survive both.
|
|
68
|
+
*/
|
|
69
|
+
body?: unknown;
|
|
48
70
|
res?: ResponseLike;
|
|
49
71
|
}
|
|
50
72
|
interface RouteState {
|
|
@@ -63,6 +85,34 @@ export default class AuthRequest extends Request {
|
|
|
63
85
|
} | 500 | 400 | undefined>;
|
|
64
86
|
'/logout': ({ headers }: RouteRequest) => void;
|
|
65
87
|
};
|
|
88
|
+
post: {
|
|
89
|
+
/**
|
|
90
|
+
* Redeems the exchange ticket from the callback redirect (#45).
|
|
91
|
+
*
|
|
92
|
+
* `POST` and not `GET` because a `GET` would put the ticket back in a
|
|
93
|
+
* URL — in the caller's history, in access logs — which is the defect
|
|
94
|
+
* this route exists to close.
|
|
95
|
+
*
|
|
96
|
+
* `application/json` and not form-encoded: `@stonyx/rest-server`
|
|
97
|
+
* installs `express.json()` only, so a form-encoded body arrives as
|
|
98
|
+
* `null` and the ticket is unreadable. Measured, not assumed.
|
|
99
|
+
*
|
|
100
|
+
* Unknown, spent and expired tickets are one indistinguishable `400`.
|
|
101
|
+
*
|
|
102
|
+
* `Cache-Control: no-store` because the `200` body is the session id —
|
|
103
|
+
* the bearer credential itself. A `POST` response is not cacheable
|
|
104
|
+
* without explicit freshness, so this is defence in depth rather than a
|
|
105
|
+
* live defect: it is there so that no intermediary, service worker or
|
|
106
|
+
* future `GET` variant of this route can retain the credential. Set
|
|
107
|
+
* through `req.res`, the same reach-through the binding-cookie helpers
|
|
108
|
+
* use, because `@stonyx/rest-server` has no supported way for a handler
|
|
109
|
+
* to set a response header (`abofs/stonyx-rest-server#45`).
|
|
110
|
+
*/
|
|
111
|
+
'/session': (req: RouteRequest) => 400 | {
|
|
112
|
+
sessionId: string;
|
|
113
|
+
expiresAt: number;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
66
116
|
};
|
|
67
117
|
/**
|
|
68
118
|
* Whether the binding cookie is issued with `Secure`.
|
package/dist/auth-request.js
CHANGED
|
@@ -79,13 +79,37 @@ export default class AuthRequest extends Request {
|
|
|
79
79
|
// server-side, and their real callback then fails.
|
|
80
80
|
this.clearBindingCookie(req, providerName);
|
|
81
81
|
if (this.oauth.frontendCallbackUrl) {
|
|
82
|
+
// The session id is the bearer credential (`GET /auth` above
|
|
83
|
+
// authenticates from exactly this value), so it must not be
|
|
84
|
+
// written into a URL: URLs land in browser history, in `Referer`
|
|
85
|
+
// on any outbound link, in proxy and CDN access logs, and in
|
|
86
|
+
// `location.search` for every script on the landing page. What
|
|
87
|
+
// goes in the URL instead is a single-use 60-second ticket that
|
|
88
|
+
// authenticates nothing, redeemed at `POST /auth/session` (#45).
|
|
89
|
+
//
|
|
90
|
+
// The ticket rides in the *fragment*, not the query. A fragment is
|
|
91
|
+
// never transmitted to any server by any user agent: it is absent
|
|
92
|
+
// from the frontend's own access logs, from every reverse proxy
|
|
93
|
+
// and CDN in front of the landing page, and from `Referer` under
|
|
94
|
+
// every referrer policy. That removes two of the four leak vectors
|
|
95
|
+
// #45 names outright, for one character. What it does not remove
|
|
96
|
+
// is browser history and readability by page scripts — those are
|
|
97
|
+
// why the ticket is still single-use and 60-second, and why the
|
|
98
|
+
// documented migration scrubs it with `history.replaceState`.
|
|
99
|
+
//
|
|
100
|
+
// `expiresAt` rides along in the same fragment rather than staying
|
|
101
|
+
// in the query, so the consumer has one place to read from.
|
|
102
|
+
// It is not a credential and nothing authenticates from it.
|
|
82
103
|
const params = new URLSearchParams({
|
|
83
|
-
|
|
104
|
+
ticket: this.oauth.issueExchangeTicket(session),
|
|
84
105
|
expiresAt: String(session.expiresAt),
|
|
85
106
|
});
|
|
86
|
-
state.redirect = `${this.oauth.frontendCallbackUrl}
|
|
107
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}#${params}`;
|
|
87
108
|
return;
|
|
88
109
|
}
|
|
110
|
+
// No `frontendCallbackUrl` configured: the session is the response
|
|
111
|
+
// body of a direct request, not a value handed to a browser through
|
|
112
|
+
// a URL, so there is nothing here for #45 to fix.
|
|
89
113
|
return session;
|
|
90
114
|
}
|
|
91
115
|
catch {
|
|
@@ -101,7 +125,43 @@ export default class AuthRequest extends Request {
|
|
|
101
125
|
if (sessionId)
|
|
102
126
|
this.oauth.logout(sessionId);
|
|
103
127
|
},
|
|
104
|
-
}
|
|
128
|
+
},
|
|
129
|
+
post: {
|
|
130
|
+
/**
|
|
131
|
+
* Redeems the exchange ticket from the callback redirect (#45).
|
|
132
|
+
*
|
|
133
|
+
* `POST` and not `GET` because a `GET` would put the ticket back in a
|
|
134
|
+
* URL — in the caller's history, in access logs — which is the defect
|
|
135
|
+
* this route exists to close.
|
|
136
|
+
*
|
|
137
|
+
* `application/json` and not form-encoded: `@stonyx/rest-server`
|
|
138
|
+
* installs `express.json()` only, so a form-encoded body arrives as
|
|
139
|
+
* `null` and the ticket is unreadable. Measured, not assumed.
|
|
140
|
+
*
|
|
141
|
+
* Unknown, spent and expired tickets are one indistinguishable `400`.
|
|
142
|
+
*
|
|
143
|
+
* `Cache-Control: no-store` because the `200` body is the session id —
|
|
144
|
+
* the bearer credential itself. A `POST` response is not cacheable
|
|
145
|
+
* without explicit freshness, so this is defence in depth rather than a
|
|
146
|
+
* live defect: it is there so that no intermediary, service worker or
|
|
147
|
+
* future `GET` variant of this route can retain the credential. Set
|
|
148
|
+
* through `req.res`, the same reach-through the binding-cookie helpers
|
|
149
|
+
* use, because `@stonyx/rest-server` has no supported way for a handler
|
|
150
|
+
* to set a response header (`abofs/stonyx-rest-server#45`).
|
|
151
|
+
*/
|
|
152
|
+
'/session': (req) => {
|
|
153
|
+
const { body, res } = req;
|
|
154
|
+
if (typeof res?.setHeader === 'function')
|
|
155
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
156
|
+
const ticket = body?.ticket;
|
|
157
|
+
if (typeof ticket !== 'string' || !ticket)
|
|
158
|
+
return 400;
|
|
159
|
+
const session = this.oauth.redeemExchangeTicket(ticket);
|
|
160
|
+
if (!session)
|
|
161
|
+
return 400;
|
|
162
|
+
return { sessionId: session.sessionId, expiresAt: session.expiresAt };
|
|
163
|
+
},
|
|
164
|
+
},
|
|
105
165
|
};
|
|
106
166
|
/**
|
|
107
167
|
* Whether the binding cookie is issued with `Secure`.
|
package/dist/main.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import TokenManager from './token-manager.js';
|
|
2
2
|
import SessionManager from './session-manager.js';
|
|
3
|
+
import TicketStore from './ticket-store.js';
|
|
4
|
+
import type { RedeemedTicket } from './ticket-store.js';
|
|
5
|
+
import type { SessionResult } from './session-manager.js';
|
|
3
6
|
import type OAuthFlow from './oauth-flow.js';
|
|
4
7
|
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
5
8
|
export declare const STATE_TTL_MS: number;
|
|
@@ -34,6 +37,7 @@ export default class OAuth {
|
|
|
34
37
|
pendingStates: Map<string, PendingState>;
|
|
35
38
|
stateTtl: number;
|
|
36
39
|
sessionManager: SessionManager;
|
|
40
|
+
ticketStore: TicketStore;
|
|
37
41
|
frontendCallbackUrl?: string;
|
|
38
42
|
constructor();
|
|
39
43
|
init(): Promise<void>;
|
|
@@ -97,9 +101,20 @@ export default class OAuth {
|
|
|
97
101
|
* `bindingValues` is every value the client presented under the binding
|
|
98
102
|
* cookie's name — see `anyCandidateMatches`.
|
|
99
103
|
*/
|
|
100
|
-
handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<
|
|
104
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<SessionResult>;
|
|
101
105
|
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
102
106
|
redirectUriFor(providerName: string): string | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
109
|
+
*
|
|
110
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
111
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
112
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
113
|
+
* heard of it.
|
|
114
|
+
*/
|
|
115
|
+
issueExchangeTicket(session: SessionResult): string;
|
|
116
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
117
|
+
redeemExchangeTicket(ticket: string): RedeemedTicket | null;
|
|
103
118
|
getSession(sessionId: string): unknown;
|
|
104
119
|
logout(sessionId: string): void;
|
|
105
120
|
}
|
package/dist/main.js
CHANGED
|
@@ -6,6 +6,7 @@ import { setup, emit } from '@stonyx/events';
|
|
|
6
6
|
import RestServer from '@stonyx/rest-server';
|
|
7
7
|
import TokenManager from './token-manager.js';
|
|
8
8
|
import SessionManager from './session-manager.js';
|
|
9
|
+
import TicketStore from './ticket-store.js';
|
|
9
10
|
import AuthRequest from './auth-request.js';
|
|
10
11
|
setup(['authenticate']);
|
|
11
12
|
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
@@ -18,6 +19,7 @@ export default class OAuth {
|
|
|
18
19
|
pendingStates = new Map();
|
|
19
20
|
stateTtl = STATE_TTL_MS;
|
|
20
21
|
sessionManager;
|
|
22
|
+
ticketStore = new TicketStore();
|
|
21
23
|
frontendCallbackUrl;
|
|
22
24
|
constructor() {
|
|
23
25
|
if (OAuth.instance)
|
|
@@ -168,6 +170,21 @@ export default class OAuth {
|
|
|
168
170
|
redirectUriFor(providerName) {
|
|
169
171
|
return this.providers.get(providerName)?.flow.redirectUri;
|
|
170
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
175
|
+
*
|
|
176
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
177
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
178
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
179
|
+
* heard of it.
|
|
180
|
+
*/
|
|
181
|
+
issueExchangeTicket(session) {
|
|
182
|
+
return this.ticketStore.issue(session.sessionId, session.expiresAt);
|
|
183
|
+
}
|
|
184
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
185
|
+
redeemExchangeTicket(ticket) {
|
|
186
|
+
return this.ticketStore.redeem(ticket);
|
|
187
|
+
}
|
|
171
188
|
getSession(sessionId) {
|
|
172
189
|
return this.sessionManager.validate(sessionId);
|
|
173
190
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifetime of an exchange ticket.
|
|
3
|
+
*
|
|
4
|
+
* Sized for one redirect plus one page load, and deliberately two orders of
|
|
5
|
+
* magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
|
|
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 — in the fragment, so it reaches no server, but
|
|
8
|
+
* still into browser history and readable by scripts on the landing page.
|
|
9
|
+
*/
|
|
10
|
+
export declare const TICKET_TTL_MS: number;
|
|
11
|
+
/** Entropy of a ticket, in bytes. */
|
|
12
|
+
export declare const TICKET_BYTES = 32;
|
|
13
|
+
interface TicketRecord {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface RedeemedTicket {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Single-use, short-lived tickets that stand in for a session id on the wire.
|
|
24
|
+
*
|
|
25
|
+
* The callback redirect hands the browser a ticket instead of the session id
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* Known residual, stated rather than papered over: a ticket observed *within*
|
|
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.
|
|
46
|
+
*
|
|
47
|
+
* Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
|
|
48
|
+
* a pre-existing pattern in this module, not something this store introduces,
|
|
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`.
|
|
76
|
+
*/
|
|
77
|
+
export default class TicketStore {
|
|
78
|
+
/**
|
|
79
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
80
|
+
*
|
|
81
|
+
* Keying by the digest means the map holds no redeemable *ticket*: a ticket
|
|
82
|
+
* is a client-presented secret looked up server-side, so what a reader of
|
|
83
|
+
* this map gets is a digest, and a digest cannot be presented to `redeem`.
|
|
84
|
+
*
|
|
85
|
+
* That does not make the map safe to expose. The record *value* holds a
|
|
86
|
+
* plaintext, live `sessionId` — the 24-hour bearer credential this store
|
|
87
|
+
* exists to keep out of URLs — so a heap dump, a debug serialisation or an
|
|
88
|
+
* accidental log of this map yields live session ids. The map is sensitive
|
|
89
|
+
* on that basis and must not be dumped or logged. Whether the stored
|
|
90
|
+
* `sessionId` should itself be protected is a separate question, and is not
|
|
91
|
+
* settled here.
|
|
92
|
+
*
|
|
93
|
+
* This is the mirror image of `OAuth.pendingStates`, not the same shape:
|
|
94
|
+
* there the *key* is the plaintext state token and the digest
|
|
95
|
+
* (`bindingHash`) sits in the value, so that record unlocks nothing on its
|
|
96
|
+
* own; here the digest is the key and the value is a live credential. What
|
|
97
|
+
* the two stores share is the discipline of never keeping a
|
|
98
|
+
* client-presented secret in the clear — neither the ticket nor the binding
|
|
99
|
+
* value is on the heap — but they place the digest on opposite sides of the
|
|
100
|
+
* entry.
|
|
101
|
+
*
|
|
102
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
103
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
104
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
105
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
106
|
+
*/
|
|
107
|
+
tickets: Map<string, TicketRecord>;
|
|
108
|
+
ttl: number;
|
|
109
|
+
/** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
|
|
110
|
+
static hash(ticket: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* Mints a ticket for a freshly created session.
|
|
113
|
+
*
|
|
114
|
+
* The ticket is independent entropy, never a transform of the session id:
|
|
115
|
+
* anything derived from the credential is the credential.
|
|
116
|
+
*/
|
|
117
|
+
issue(sessionId: string, expiresAt: number): string;
|
|
118
|
+
/**
|
|
119
|
+
* Spends a ticket, if it is live.
|
|
120
|
+
*
|
|
121
|
+
* Consumed on recognition, *before* the TTL check, for the same reason
|
|
122
|
+
* `OAuth.handleCallback` consumes a pending state before validating its
|
|
123
|
+
* binding: every ticket gets exactly one attempt whatever the outcome, so
|
|
124
|
+
* this endpoint is never a repeatable oracle. Deleting after the TTL check
|
|
125
|
+
* instead would leave an expired ticket in the map answering `400` forever
|
|
126
|
+
* while a live one answers `200` — an unauthenticated distinguisher.
|
|
127
|
+
*
|
|
128
|
+
* Returns `null` for unknown, spent and expired tickets alike. The caller
|
|
129
|
+
* maps all three to the same `400`; telling them apart is information the
|
|
130
|
+
* holder of a ticket they did not mint has no business having.
|
|
131
|
+
*/
|
|
132
|
+
redeem(ticket: string): RedeemedTicket | null;
|
|
133
|
+
}
|
|
134
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Lifetime of an exchange ticket.
|
|
4
|
+
*
|
|
5
|
+
* Sized for one redirect plus one page load, and deliberately two orders of
|
|
6
|
+
* magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
|
|
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 — in the fragment, so it reaches no server, but
|
|
9
|
+
* still into browser history and readable by scripts on the landing page.
|
|
10
|
+
*/
|
|
11
|
+
export const TICKET_TTL_MS = 60 * 1000;
|
|
12
|
+
/** Entropy of a ticket, in bytes. */
|
|
13
|
+
export const TICKET_BYTES = 32;
|
|
14
|
+
/**
|
|
15
|
+
* Single-use, short-lived tickets that stand in for a session id on the wire.
|
|
16
|
+
*
|
|
17
|
+
* The callback redirect hands the browser a ticket instead of the session id
|
|
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.
|
|
24
|
+
*
|
|
25
|
+
* Known residual, stated rather than papered over: a ticket observed *within*
|
|
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.
|
|
38
|
+
*
|
|
39
|
+
* Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
|
|
40
|
+
* a pre-existing pattern in this module, not something this store introduces,
|
|
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`.
|
|
68
|
+
*/
|
|
69
|
+
export default class TicketStore {
|
|
70
|
+
/**
|
|
71
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
72
|
+
*
|
|
73
|
+
* Keying by the digest means the map holds no redeemable *ticket*: a ticket
|
|
74
|
+
* is a client-presented secret looked up server-side, so what a reader of
|
|
75
|
+
* this map gets is a digest, and a digest cannot be presented to `redeem`.
|
|
76
|
+
*
|
|
77
|
+
* That does not make the map safe to expose. The record *value* holds a
|
|
78
|
+
* plaintext, live `sessionId` — the 24-hour bearer credential this store
|
|
79
|
+
* exists to keep out of URLs — so a heap dump, a debug serialisation or an
|
|
80
|
+
* accidental log of this map yields live session ids. The map is sensitive
|
|
81
|
+
* on that basis and must not be dumped or logged. Whether the stored
|
|
82
|
+
* `sessionId` should itself be protected is a separate question, and is not
|
|
83
|
+
* settled here.
|
|
84
|
+
*
|
|
85
|
+
* This is the mirror image of `OAuth.pendingStates`, not the same shape:
|
|
86
|
+
* there the *key* is the plaintext state token and the digest
|
|
87
|
+
* (`bindingHash`) sits in the value, so that record unlocks nothing on its
|
|
88
|
+
* own; here the digest is the key and the value is a live credential. What
|
|
89
|
+
* the two stores share is the discipline of never keeping a
|
|
90
|
+
* client-presented secret in the clear — neither the ticket nor the binding
|
|
91
|
+
* value is on the heap — but they place the digest on opposite sides of the
|
|
92
|
+
* entry.
|
|
93
|
+
*
|
|
94
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
95
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
96
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
97
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
98
|
+
*/
|
|
99
|
+
tickets = new Map();
|
|
100
|
+
ttl = TICKET_TTL_MS;
|
|
101
|
+
/** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
|
|
102
|
+
static hash(ticket) {
|
|
103
|
+
return createHash('sha256').update(ticket).digest('hex');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Mints a ticket for a freshly created session.
|
|
107
|
+
*
|
|
108
|
+
* The ticket is independent entropy, never a transform of the session id:
|
|
109
|
+
* anything derived from the credential is the credential.
|
|
110
|
+
*/
|
|
111
|
+
issue(sessionId, expiresAt) {
|
|
112
|
+
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
113
|
+
this.tickets.set(TicketStore.hash(ticket), { sessionId, expiresAt, createdAt: Date.now() });
|
|
114
|
+
return ticket;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Spends a ticket, if it is live.
|
|
118
|
+
*
|
|
119
|
+
* Consumed on recognition, *before* the TTL check, for the same reason
|
|
120
|
+
* `OAuth.handleCallback` consumes a pending state before validating its
|
|
121
|
+
* binding: every ticket gets exactly one attempt whatever the outcome, so
|
|
122
|
+
* this endpoint is never a repeatable oracle. Deleting after the TTL check
|
|
123
|
+
* instead would leave an expired ticket in the map answering `400` forever
|
|
124
|
+
* while a live one answers `200` — an unauthenticated distinguisher.
|
|
125
|
+
*
|
|
126
|
+
* Returns `null` for unknown, spent and expired tickets alike. The caller
|
|
127
|
+
* maps all three to the same `400`; telling them apart is information the
|
|
128
|
+
* holder of a ticket they did not mint has no business having.
|
|
129
|
+
*/
|
|
130
|
+
redeem(ticket) {
|
|
131
|
+
const key = ticket ? TicketStore.hash(ticket) : null;
|
|
132
|
+
const record = key ? this.tickets.get(key) : undefined;
|
|
133
|
+
if (!record)
|
|
134
|
+
return null;
|
|
135
|
+
this.tickets.delete(key);
|
|
136
|
+
if (Date.now() - record.createdAt > this.ttl)
|
|
137
|
+
return null;
|
|
138
|
+
return { sessionId: record.sessionId, expiresAt: record.expiresAt };
|
|
139
|
+
}
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.1-beta.
|
|
7
|
+
"version": "0.1.1-beta.187",
|
|
8
8
|
"description": "OAuth2 authentication module for the Stonyx framework",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.123",
|
|
65
65
|
"@stonyx/utils": "0.2.3-beta.27",
|
|
66
66
|
"@stonyx/logs": "1.0.1-beta.21",
|
|
67
67
|
"@types/qunit": "^2.19.13",
|
package/src/auth-request.ts
CHANGED
|
@@ -40,6 +40,8 @@ interface OAuthInstance {
|
|
|
40
40
|
stateToken: string,
|
|
41
41
|
bindingValues: readonly string[],
|
|
42
42
|
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
43
|
+
issueExchangeTicket(session: { sessionId: string; expiresAt: number }): string;
|
|
44
|
+
redeemExchangeTicket(ticket: string): { sessionId: string; expiresAt: number } | null;
|
|
43
45
|
logout(sessionId: string): void;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -67,12 +69,26 @@ export interface CookieOptions {
|
|
|
67
69
|
interface ResponseLike {
|
|
68
70
|
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
69
71
|
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
72
|
+
/**
|
|
73
|
+
* Optional: every call site guards on it. `@stonyx/rest-server` hands the
|
|
74
|
+
* express response through untyped, and a test double need not implement the
|
|
75
|
+
* whole surface.
|
|
76
|
+
*/
|
|
77
|
+
setHeader?(name: string, value: string): unknown;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
interface RouteRequest {
|
|
73
81
|
headers: Record<string, string | undefined>;
|
|
74
82
|
params: Record<string, string>;
|
|
75
83
|
query: Record<string, string>;
|
|
84
|
+
/**
|
|
85
|
+
* Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
|
|
86
|
+
*
|
|
87
|
+
* Optional and typed loosely because it is whatever an unauthenticated
|
|
88
|
+
* caller sent: a form-encoded body arrives as `null` and a bodyless request
|
|
89
|
+
* as `undefined`, so every read of it has to survive both.
|
|
90
|
+
*/
|
|
91
|
+
body?: unknown;
|
|
76
92
|
res?: ResponseLike;
|
|
77
93
|
}
|
|
78
94
|
|
|
@@ -155,14 +171,38 @@ export default class AuthRequest extends Request {
|
|
|
155
171
|
this.clearBindingCookie(req, providerName);
|
|
156
172
|
|
|
157
173
|
if (this.oauth.frontendCallbackUrl) {
|
|
174
|
+
// The session id is the bearer credential (`GET /auth` above
|
|
175
|
+
// authenticates from exactly this value), so it must not be
|
|
176
|
+
// written into a URL: URLs land in browser history, in `Referer`
|
|
177
|
+
// on any outbound link, in proxy and CDN access logs, and in
|
|
178
|
+
// `location.search` for every script on the landing page. What
|
|
179
|
+
// goes in the URL instead is a single-use 60-second ticket that
|
|
180
|
+
// authenticates nothing, redeemed at `POST /auth/session` (#45).
|
|
181
|
+
//
|
|
182
|
+
// The ticket rides in the *fragment*, not the query. A fragment is
|
|
183
|
+
// never transmitted to any server by any user agent: it is absent
|
|
184
|
+
// from the frontend's own access logs, from every reverse proxy
|
|
185
|
+
// and CDN in front of the landing page, and from `Referer` under
|
|
186
|
+
// every referrer policy. That removes two of the four leak vectors
|
|
187
|
+
// #45 names outright, for one character. What it does not remove
|
|
188
|
+
// is browser history and readability by page scripts — those are
|
|
189
|
+
// why the ticket is still single-use and 60-second, and why the
|
|
190
|
+
// documented migration scrubs it with `history.replaceState`.
|
|
191
|
+
//
|
|
192
|
+
// `expiresAt` rides along in the same fragment rather than staying
|
|
193
|
+
// in the query, so the consumer has one place to read from.
|
|
194
|
+
// It is not a credential and nothing authenticates from it.
|
|
158
195
|
const params = new URLSearchParams({
|
|
159
|
-
|
|
196
|
+
ticket: this.oauth.issueExchangeTicket(session),
|
|
160
197
|
expiresAt: String(session.expiresAt),
|
|
161
198
|
});
|
|
162
|
-
state.redirect = `${this.oauth.frontendCallbackUrl}
|
|
199
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}#${params}`;
|
|
163
200
|
return;
|
|
164
201
|
}
|
|
165
202
|
|
|
203
|
+
// No `frontendCallbackUrl` configured: the session is the response
|
|
204
|
+
// body of a direct request, not a value handed to a browser through
|
|
205
|
+
// a URL, so there is nothing here for #45 to fix.
|
|
166
206
|
return session;
|
|
167
207
|
} catch {
|
|
168
208
|
if (this.oauth.frontendCallbackUrl) {
|
|
@@ -177,7 +217,44 @@ export default class AuthRequest extends Request {
|
|
|
177
217
|
const sessionId = headers['session-id'];
|
|
178
218
|
if (sessionId) this.oauth.logout(sessionId);
|
|
179
219
|
},
|
|
180
|
-
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
post: {
|
|
223
|
+
/**
|
|
224
|
+
* Redeems the exchange ticket from the callback redirect (#45).
|
|
225
|
+
*
|
|
226
|
+
* `POST` and not `GET` because a `GET` would put the ticket back in a
|
|
227
|
+
* URL — in the caller's history, in access logs — which is the defect
|
|
228
|
+
* this route exists to close.
|
|
229
|
+
*
|
|
230
|
+
* `application/json` and not form-encoded: `@stonyx/rest-server`
|
|
231
|
+
* installs `express.json()` only, so a form-encoded body arrives as
|
|
232
|
+
* `null` and the ticket is unreadable. Measured, not assumed.
|
|
233
|
+
*
|
|
234
|
+
* Unknown, spent and expired tickets are one indistinguishable `400`.
|
|
235
|
+
*
|
|
236
|
+
* `Cache-Control: no-store` because the `200` body is the session id —
|
|
237
|
+
* the bearer credential itself. A `POST` response is not cacheable
|
|
238
|
+
* without explicit freshness, so this is defence in depth rather than a
|
|
239
|
+
* live defect: it is there so that no intermediary, service worker or
|
|
240
|
+
* future `GET` variant of this route can retain the credential. Set
|
|
241
|
+
* through `req.res`, the same reach-through the binding-cookie helpers
|
|
242
|
+
* use, because `@stonyx/rest-server` has no supported way for a handler
|
|
243
|
+
* to set a response header (`abofs/stonyx-rest-server#45`).
|
|
244
|
+
*/
|
|
245
|
+
'/session': (req: RouteRequest) => {
|
|
246
|
+
const { body, res } = req;
|
|
247
|
+
if (typeof res?.setHeader === 'function') res.setHeader('Cache-Control', 'no-store');
|
|
248
|
+
|
|
249
|
+
const ticket = (body as { ticket?: unknown } | null | undefined)?.ticket;
|
|
250
|
+
if (typeof ticket !== 'string' || !ticket) return 400;
|
|
251
|
+
|
|
252
|
+
const session = this.oauth.redeemExchangeTicket(ticket);
|
|
253
|
+
if (!session) return 400;
|
|
254
|
+
|
|
255
|
+
return { sessionId: session.sessionId, expiresAt: session.expiresAt };
|
|
256
|
+
},
|
|
257
|
+
},
|
|
181
258
|
};
|
|
182
259
|
|
|
183
260
|
/**
|
package/src/main.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { setup, emit } from '@stonyx/events';
|
|
|
6
6
|
import RestServer from '@stonyx/rest-server';
|
|
7
7
|
import TokenManager from './token-manager.js';
|
|
8
8
|
import SessionManager from './session-manager.js';
|
|
9
|
+
import TicketStore from './ticket-store.js';
|
|
9
10
|
import AuthRequest from './auth-request.js';
|
|
11
|
+
import type { RedeemedTicket } from './ticket-store.js';
|
|
12
|
+
import type { SessionResult } from './session-manager.js';
|
|
10
13
|
import type OAuthFlow from './oauth-flow.js';
|
|
11
14
|
|
|
12
15
|
setup(['authenticate']);
|
|
@@ -55,6 +58,7 @@ export default class OAuth {
|
|
|
55
58
|
pendingStates = new Map<string, PendingState>();
|
|
56
59
|
stateTtl = STATE_TTL_MS;
|
|
57
60
|
sessionManager!: SessionManager;
|
|
61
|
+
ticketStore = new TicketStore();
|
|
58
62
|
frontendCallbackUrl?: string;
|
|
59
63
|
|
|
60
64
|
constructor() {
|
|
@@ -228,6 +232,23 @@ export default class OAuth {
|
|
|
228
232
|
return this.providers.get(providerName)?.flow.redirectUri;
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
237
|
+
*
|
|
238
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
239
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
240
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
241
|
+
* heard of it.
|
|
242
|
+
*/
|
|
243
|
+
issueExchangeTicket(session: SessionResult): string {
|
|
244
|
+
return this.ticketStore.issue(session.sessionId, session.expiresAt);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
248
|
+
redeemExchangeTicket(ticket: string): RedeemedTicket | null {
|
|
249
|
+
return this.ticketStore.redeem(ticket);
|
|
250
|
+
}
|
|
251
|
+
|
|
231
252
|
getSession(sessionId: string) {
|
|
232
253
|
return this.sessionManager.validate(sessionId);
|
|
233
254
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifetime of an exchange ticket.
|
|
5
|
+
*
|
|
6
|
+
* Sized for one redirect plus one page load, and deliberately two orders of
|
|
7
|
+
* magnitude tighter than the 10-minute state TTL: the ticket is a bearer value
|
|
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 — in the fragment, so it reaches no server, but
|
|
10
|
+
* still into browser history and readable by scripts on the landing page.
|
|
11
|
+
*/
|
|
12
|
+
export const TICKET_TTL_MS = 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Entropy of a ticket, in bytes. */
|
|
15
|
+
export const TICKET_BYTES = 32;
|
|
16
|
+
|
|
17
|
+
interface TicketRecord {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RedeemedTicket {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Single-use, short-lived tickets that stand in for a session id on the wire.
|
|
30
|
+
*
|
|
31
|
+
* The callback redirect hands the browser a ticket instead of the session id
|
|
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.
|
|
38
|
+
*
|
|
39
|
+
* Known residual, stated rather than papered over: a ticket observed *within*
|
|
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.
|
|
52
|
+
*
|
|
53
|
+
* Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
|
|
54
|
+
* a pre-existing pattern in this module, not something this store introduces,
|
|
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`.
|
|
82
|
+
*/
|
|
83
|
+
export default class TicketStore {
|
|
84
|
+
/**
|
|
85
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
86
|
+
*
|
|
87
|
+
* Keying by the digest means the map holds no redeemable *ticket*: a ticket
|
|
88
|
+
* is a client-presented secret looked up server-side, so what a reader of
|
|
89
|
+
* this map gets is a digest, and a digest cannot be presented to `redeem`.
|
|
90
|
+
*
|
|
91
|
+
* That does not make the map safe to expose. The record *value* holds a
|
|
92
|
+
* plaintext, live `sessionId` — the 24-hour bearer credential this store
|
|
93
|
+
* exists to keep out of URLs — so a heap dump, a debug serialisation or an
|
|
94
|
+
* accidental log of this map yields live session ids. The map is sensitive
|
|
95
|
+
* on that basis and must not be dumped or logged. Whether the stored
|
|
96
|
+
* `sessionId` should itself be protected is a separate question, and is not
|
|
97
|
+
* settled here.
|
|
98
|
+
*
|
|
99
|
+
* This is the mirror image of `OAuth.pendingStates`, not the same shape:
|
|
100
|
+
* there the *key* is the plaintext state token and the digest
|
|
101
|
+
* (`bindingHash`) sits in the value, so that record unlocks nothing on its
|
|
102
|
+
* own; here the digest is the key and the value is a live credential. What
|
|
103
|
+
* the two stores share is the discipline of never keeping a
|
|
104
|
+
* client-presented secret in the clear — neither the ticket nor the binding
|
|
105
|
+
* value is on the heap — but they place the digest on opposite sides of the
|
|
106
|
+
* entry.
|
|
107
|
+
*
|
|
108
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
109
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
110
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
111
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
112
|
+
*/
|
|
113
|
+
tickets = new Map<string, TicketRecord>();
|
|
114
|
+
ttl = TICKET_TTL_MS;
|
|
115
|
+
|
|
116
|
+
/** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
|
|
117
|
+
static hash(ticket: string): string {
|
|
118
|
+
return createHash('sha256').update(ticket).digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Mints a ticket for a freshly created session.
|
|
123
|
+
*
|
|
124
|
+
* The ticket is independent entropy, never a transform of the session id:
|
|
125
|
+
* anything derived from the credential is the credential.
|
|
126
|
+
*/
|
|
127
|
+
issue(sessionId: string, expiresAt: number): string {
|
|
128
|
+
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
129
|
+
this.tickets.set(TicketStore.hash(ticket), { sessionId, expiresAt, createdAt: Date.now() });
|
|
130
|
+
return ticket;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Spends a ticket, if it is live.
|
|
135
|
+
*
|
|
136
|
+
* Consumed on recognition, *before* the TTL check, for the same reason
|
|
137
|
+
* `OAuth.handleCallback` consumes a pending state before validating its
|
|
138
|
+
* binding: every ticket gets exactly one attempt whatever the outcome, so
|
|
139
|
+
* this endpoint is never a repeatable oracle. Deleting after the TTL check
|
|
140
|
+
* instead would leave an expired ticket in the map answering `400` forever
|
|
141
|
+
* while a live one answers `200` — an unauthenticated distinguisher.
|
|
142
|
+
*
|
|
143
|
+
* Returns `null` for unknown, spent and expired tickets alike. The caller
|
|
144
|
+
* maps all three to the same `400`; telling them apart is information the
|
|
145
|
+
* holder of a ticket they did not mint has no business having.
|
|
146
|
+
*/
|
|
147
|
+
redeem(ticket: string): RedeemedTicket | null {
|
|
148
|
+
const key = ticket ? TicketStore.hash(ticket) : null;
|
|
149
|
+
const record = key ? this.tickets.get(key) : undefined;
|
|
150
|
+
if (!record) return null;
|
|
151
|
+
|
|
152
|
+
this.tickets.delete(key!);
|
|
153
|
+
|
|
154
|
+
if (Date.now() - record.createdAt > this.ttl) return null;
|
|
155
|
+
|
|
156
|
+
return { sessionId: record.sessionId, expiresAt: record.expiresAt };
|
|
157
|
+
}
|
|
158
|
+
}
|