@stonyx/oauth 0.1.1-beta.20 → 0.1.1-beta.201

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.
Files changed (41) hide show
  1. package/README.md +193 -2
  2. package/dist/auth-request.d.ts +147 -0
  3. package/dist/auth-request.js +247 -0
  4. package/dist/main.d.ts +121 -0
  5. package/dist/main.js +194 -0
  6. package/dist/oauth-flow.d.ts +30 -0
  7. package/dist/oauth-flow.js +83 -0
  8. package/dist/providers/discord.d.ts +30 -0
  9. package/dist/providers/discord.js +43 -0
  10. package/dist/session-manager.d.ts +20 -0
  11. package/dist/session-manager.js +30 -0
  12. package/dist/ticket-store.d.ts +134 -0
  13. package/dist/ticket-store.js +140 -0
  14. package/dist/token-manager.d.ts +15 -0
  15. package/dist/token-manager.js +24 -0
  16. package/package.json +45 -9
  17. package/src/auth-request.ts +348 -0
  18. package/src/main.ts +259 -0
  19. package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
  20. package/src/providers/{discord.js → discord.ts} +29 -3
  21. package/src/{session-manager.js → session-manager.ts} +19 -6
  22. package/src/ticket-store.ts +158 -0
  23. package/src/token-manager.ts +35 -0
  24. package/src/types/node.d.ts +19 -0
  25. package/src/types/stonyx-events.d.ts +4 -0
  26. package/src/types/stonyx-rest-server.d.ts +11 -0
  27. package/src/types/stonyx.d.ts +38 -0
  28. package/.github/workflows/ci.yml +0 -16
  29. package/.github/workflows/publish.yml +0 -51
  30. package/src/auth-request.js +0 -74
  31. package/src/main.js +0 -83
  32. package/src/token-manager.js +0 -26
  33. package/test/config/environment.js +0 -18
  34. package/test/integration/oauth-test.js +0 -149
  35. package/test/sample/providers/mock.js +0 -40
  36. package/test/sample/requests/.gitkeep +0 -0
  37. package/test/unit/oauth-flow-test.js +0 -137
  38. package/test/unit/providers/discord-test.js +0 -115
  39. package/test/unit/session-manager-test.js +0 -85
  40. package/test/unit/state-validation-test.js +0 -118
  41. package/test/unit/token-manager-test.js +0 -76
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ [![CI](https://github.com/abofs/stonyx-oauth/actions/workflows/ci.yml/badge.svg)](https://github.com/abofs/stonyx-oauth/actions/workflows/ci.yml)
2
+ [![npm version](https://img.shields.io/npm/v/@stonyx/oauth.svg)](https://www.npmjs.com/package/@stonyx/oauth)
3
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
4
+
1
5
  # @stonyx/oauth
2
6
 
3
7
  OAuth2 authentication module for the Stonyx framework. Provides a generic OAuth2 Authorization Code flow with a provider pattern — ship with Discord support, extensible to any OAuth2 provider.
@@ -43,6 +47,9 @@ By default no providers are enabled. Add providers as keys in the `providers` ob
43
47
  |--------|---------|-------------|
44
48
  | `providers` | `{}` | Map of provider name to config |
45
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).
46
53
 
47
54
  ## Routes
48
55
 
@@ -52,7 +59,8 @@ The module self-registers the following routes on the rest server:
52
59
  |--------|-------|-------------|
53
60
  | `GET` | `/auth` | Validate session — send `session-id` header, returns user or 401 |
54
61
  | `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page |
55
- | `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": "..." }` |
56
64
  | `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
57
65
 
58
66
  ## Officially Supported Providers
@@ -112,11 +120,194 @@ providers: {
112
120
  }
113
121
  ```
114
122
 
123
+ ## Login CSRF protection — the `oauth_state` cookie
124
+
125
+ ### Breaking changes (#36)
126
+
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.
128
+
129
+ **1. The login flow now requires a cookie jar.** A client that cannot hold a cookie between `/auth/login/:provider` and `/auth/callback/:provider` can no longer complete a login. That is the point of the change — see [Migration](#migration-from-a-cookie-less-client) below.
130
+
131
+ **2. The JS API changed shape.** This one is invisible to anyone who only reads the cookie disclosure above. If you import the module's default export and call it directly — wrapping it, monkeypatching it, or driving it in tests — three things changed:
132
+
133
+ | | Before | After |
134
+ |---|--------|-------|
135
+ | `getAuthorizationUrl(provider)` | returns the authorization URL as a `string` | returns `{ url, stateToken, bindingValue }` |
136
+ | `handleCallback(provider, code, state)` | three arguments | requires a fourth, `bindingValues: readonly string[]` — every value the caller presented under the `oauth_state` cookie name |
137
+ | `pendingStates` values | `number` (a creation timestamp) | `{ bindingHash, createdAt }` |
138
+
139
+ None of these throws at import time, and a `typeof … === 'function'` surface check passes on all three: the arity and the return type change, not the presence. Callers must be updated by inspection.
140
+
141
+ The HTTP contract is otherwise unchanged — the [route table](#routes) is the same, no config key was added or changed, and both routes still `302` on their success paths. One status is new: `/auth/login/:provider` returns `500` when the binding cookie cannot be set, which it never did before (see below).
142
+
143
+ `GET /auth/login/:provider` issues an `oauth_state` cookie carrying a per-flow binding value, and keeps only its SHA-256 server-side. `GET /auth/callback/:provider` accepts an OAuth2 `state` only from a caller that also presents the matching cookie value.
144
+
145
+ Without it, `state` was verified by membership in a server-side map plus an age bound, and nothing else. There was no value the browser that started the flow carried that another browser did not, so an attacker could start a login, harvest their own `state` and `code`, deliver them to a victim over a plain link, and log that victim into the *attacker's* account (RFC 6749 §10.12, RFC 9700). The victim's own account and data are not exposed; what is at risk is whatever they author afterwards, believing the session is theirs.
146
+
147
+ ### Cookie attributes
148
+
149
+ | Attribute | Value | Why |
150
+ |-----------|-------|-----|
151
+ | Name | `oauth_state` | Issued at login, cleared on a successful callback. The *state* is single-use; the cookie name is fixed, so it is not — see [Concurrent logins](#concurrent-logins-in-the-same-browser) |
152
+ | `HttpOnly` | always | Script must not be able to read or forge the binding value |
153
+ | `SameSite` | `Lax` | **Required.** The callback is a cross-site, top-level GET navigation from the provider. `Strict` withholds the cookie on exactly that request and breaks every login |
154
+ | `Path` | `/` | Routing is case-insensitive; RFC 6265 `Path` matching is not. A narrower path silently drops the cookie on a case-varied callback |
155
+ | `Secure` | when the provider's `redirectUri` is not `http:` | Derived from your configured redirect URI, so plaintext local development works and a TLS deployment behind a terminating proxy still gets `Secure` |
156
+ | `Max-Age` | 600 seconds | Matches the server-side state TTL |
157
+
158
+ If the runtime cannot set the cookie, `/auth/login/:provider` returns `500` and issues no state, rather than issuing one that cannot be bound.
159
+
160
+ ### Requirements for consumers
161
+
162
+ - **Start the login as a top-level navigation** (`window.location = '/auth/login/discord'`, or a plain link). This is the documented pattern and it avoids CORS entirely.
163
+ - **Serve login and callback from the same host.** The cookie is host-scoped and carries no `Domain` attribute. A **different port on the same host is fine** — port is not part of cookie scope (RFC 6265 §8.5) — but a different *hostname* is not: the cookie is never sent and the login fails. Both routes are mounted on the same `AuthRequest`, so this only bites when something in front of the app splits them across hostnames (a proxy split, or `app.example.com` for login and `example.com` for the callback).
164
+ - **Keep your configured `redirectUri` on the same scheme the login endpoint is served over.** `Secure` is derived from `redirectUri`, so an `https` `redirectUri` behind a plaintext login endpoint issues a `Secure` cookie that the browser silently discards. Every login then fails the binding check **with no server-side signal** — the callback simply reports `error=auth_failed` if `frontendCallbackUrl` is configured, or a bare `500` if it is not. Check this first if logins start failing after a TLS or proxy change.
165
+ - An XHR-initiated login will not work: `@stonyx/rest-server` never passes `credentials: true` to CORS, so the browser will neither store nor send the cookie on a cross-origin XHR. Narrowing `REST_CORS_ORIGIN` from its `*` default does not change this.
166
+
167
+ ### Migration from a cookie-less client
168
+
169
+ Scripted and server-to-server logins break. If you drive the flow yourself, carry the `Set-Cookie` from the login response back as a `Cookie` header on the callback:
170
+
171
+ ```javascript
172
+ const login = await fetch(`${host}/auth/login/discord`, { redirect: 'manual' });
173
+ const cookie = login.headers.getSetCookie().map(header => header.split(';')[0]).join('; ');
174
+ const state = new URL(login.headers.get('location')).searchParams.get('state');
175
+
176
+ // ...provider redirects back with `code`...
177
+ await fetch(`${host}/auth/callback/discord?code=${code}&state=${state}`, {
178
+ redirect: 'manual',
179
+ headers: { cookie },
180
+ });
181
+ ```
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
+
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.
186
+
187
+ ### Concurrent logins in the same browser
188
+
189
+ The cookie name is fixed and its `Path` is `/`, so a second login started in the same browser overwrites the first tab's binding value. The first tab's callback then presents the second tab's value and fails the binding check — redirecting with `error=auth_failed` if `frontendCallbackUrl` is configured, or returning `500` if it is not.
190
+
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.
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
+
115
306
  ## Session Management
116
307
 
117
308
  Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
118
309
 
119
- Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
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.
120
311
 
121
312
  ## License
122
313
 
@@ -0,0 +1,147 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ interface AuthorizationRequest {
3
+ url: string;
4
+ stateToken: string;
5
+ bindingValue: string;
6
+ }
7
+ interface OAuthInstance {
8
+ frontendCallbackUrl?: string;
9
+ stateTtl: number;
10
+ getSession(sessionId: string): unknown;
11
+ getAuthorizationUrl(providerName: string): AuthorizationRequest;
12
+ discardState(stateToken: string): void;
13
+ redirectUriFor(providerName: string): string | undefined;
14
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<{
15
+ sessionId: string;
16
+ expiresAt: number;
17
+ }>;
18
+ issueExchangeTicket(session: {
19
+ sessionId: string;
20
+ expiresAt: number;
21
+ }): string;
22
+ redeemExchangeTicket(ticket: string): {
23
+ sessionId: string;
24
+ expiresAt: number;
25
+ } | null;
26
+ logout(sessionId: string): void;
27
+ }
28
+ export interface CookieOptions {
29
+ httpOnly: boolean;
30
+ sameSite: string;
31
+ path: string;
32
+ secure: boolean;
33
+ maxAge?: number;
34
+ }
35
+ /**
36
+ * The response object express hangs off the request.
37
+ *
38
+ * `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
39
+ * is unreachable once `state.redirect` is set (`request.ts` returns on the
40
+ * redirect first), so setting a cookie means reaching for `req.res`.
41
+ *
42
+ * This is a deliberate, sanctioned interim reach-around, not an accident:
43
+ * `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
44
+ * first-class header/cookie affordance to migrate onto, and it is sequenced
45
+ * after this fix. `setBindingCookie` fails closed if the affordance is not
46
+ * there, which is what contains the dependency.
47
+ */
48
+ interface ResponseLike {
49
+ cookie(name: string, value: string, options: CookieOptions): unknown;
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;
57
+ }
58
+ interface RouteRequest {
59
+ headers: Record<string, string | undefined>;
60
+ params: Record<string, string>;
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;
70
+ res?: ResponseLike;
71
+ }
72
+ interface RouteState {
73
+ redirect?: string;
74
+ }
75
+ export default class AuthRequest extends Request {
76
+ oauth: OAuthInstance;
77
+ constructor(oauth: OAuthInstance);
78
+ handlers: {
79
+ get: {
80
+ '/': ({ headers }: RouteRequest) => {};
81
+ '/login/:provider': (req: RouteRequest, state: RouteState) => 404 | 500 | undefined;
82
+ '/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
83
+ sessionId: string;
84
+ expiresAt: number;
85
+ } | 500 | 400 | undefined>;
86
+ '/logout': ({ headers }: RouteRequest) => void;
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
+ };
116
+ };
117
+ /**
118
+ * Whether the binding cookie is issued with `Secure`.
119
+ *
120
+ * Derived from the scheme of the provider's configured `redirectUri`, which
121
+ * is the deployment's own statement of the origin this cookie has to survive
122
+ * a round trip to.
123
+ *
124
+ * Not `req.secure`: express derives that from the socket unless `trust proxy`
125
+ * is on, and `@stonyx/rest-server` leaves it off by default, so in the
126
+ * standard production topology — TLS terminated at a proxy, plaintext to the
127
+ * origin — `req.secure` is `false` on every request to an HTTPS site and the
128
+ * cookie would ship without `Secure` while the deployment looks correct. Not
129
+ * the `Host` header either: that is attacker-controllable on any non-browser
130
+ * client. And not hardcoded `true`, which breaks plaintext local development.
131
+ *
132
+ * An unparseable or absent redirect URI fails secure.
133
+ */
134
+ isSecureContext(providerName: string): boolean;
135
+ cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'>;
136
+ setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean;
137
+ /**
138
+ * Every value the client presented under the binding cookie's name.
139
+ *
140
+ * Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
141
+ * either would hand an attacker a permanent, unauthenticated denial of login
142
+ * for any victim they can plant a same-named cookie on.
143
+ */
144
+ readBindingCookies(req: RouteRequest): string[];
145
+ clearBindingCookie(req: RouteRequest, providerName: string): void;
146
+ }
147
+ export {};
@@ -0,0 +1,247 @@
1
+ import { Request } from '@stonyx/rest-server';
2
+ import log from 'stonyx/log';
3
+ /**
4
+ * The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
5
+ *
6
+ * The attributes below are load-bearing, not cosmetic:
7
+ *
8
+ * - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
9
+ * initiated by the provider. `Strict` withholds the cookie on exactly that
10
+ * request, breaking 100% of logins while passing every CSRF test; `None`
11
+ * requires `Secure` and widens exposure for no benefit.
12
+ * - `Path=/` — routing is case-insensitive today
13
+ * (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
14
+ * RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
15
+ * `/auth` silently drops the cookie on a case-varied callback and breaks
16
+ * login.
17
+ * - `HttpOnly` — script must not be able to read or forge the binding value.
18
+ */
19
+ const STATE_COOKIE_NAME = 'oauth_state';
20
+ const STATE_COOKIE_PATH = '/';
21
+ const STATE_COOKIE_SAME_SITE = 'lax';
22
+ export default class AuthRequest extends Request {
23
+ oauth;
24
+ constructor(oauth) {
25
+ super();
26
+ this.oauth = oauth;
27
+ }
28
+ handlers = {
29
+ get: {
30
+ '/': ({ headers }) => {
31
+ const sessionId = headers['session-id'];
32
+ if (!sessionId)
33
+ return 401;
34
+ const user = this.oauth.getSession(sessionId);
35
+ if (!user)
36
+ return 401;
37
+ return user;
38
+ },
39
+ '/login/:provider': (req, state) => {
40
+ const { provider: providerName } = req.params;
41
+ let authorization;
42
+ try {
43
+ authorization = this.oauth.getAuthorizationUrl(providerName);
44
+ }
45
+ catch {
46
+ return 404;
47
+ }
48
+ // Fail closed. A state we cannot bind to this client is exactly the
49
+ // defect this mechanism exists to prevent, so it is withdrawn rather
50
+ // than issued unbindable.
51
+ if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
52
+ this.oauth.discardState(authorization.stateToken);
53
+ return 500;
54
+ }
55
+ state.redirect = authorization.url;
56
+ },
57
+ '/callback/:provider': async (req, state) => {
58
+ const { provider: providerName } = req.params;
59
+ const { code, state: stateToken, error } = req.query;
60
+ if (error) {
61
+ if (this.oauth.frontendCallbackUrl) {
62
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
63
+ return;
64
+ }
65
+ return 400;
66
+ }
67
+ if (!code)
68
+ return 400;
69
+ try {
70
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, this.readBindingCookies(req));
71
+ // Cleared only here, on the success path, which is the only path that
72
+ // is certain to have consumed a state belonging to *this* client.
73
+ //
74
+ // Clearing on failure instead looks harmless and is not: `code` is
75
+ // attacker-supplied and unvalidated, so a bare `?code=1` — no
76
+ // knowledge of anyone's state — would delete the binding cookie of a
77
+ // client still sitting on the provider's consent screen, leaving
78
+ // their pending state untouched so nothing is detectable
79
+ // server-side, and their real callback then fails.
80
+ this.clearBindingCookie(req, providerName);
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.
103
+ const params = new URLSearchParams({
104
+ ticket: this.oauth.issueExchangeTicket(session),
105
+ expiresAt: String(session.expiresAt),
106
+ });
107
+ state.redirect = `${this.oauth.frontendCallbackUrl}#${params}`;
108
+ return;
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.
113
+ return session;
114
+ }
115
+ catch {
116
+ if (this.oauth.frontendCallbackUrl) {
117
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
118
+ return;
119
+ }
120
+ return 500;
121
+ }
122
+ },
123
+ '/logout': ({ headers }) => {
124
+ const sessionId = headers['session-id'];
125
+ if (sessionId)
126
+ this.oauth.logout(sessionId);
127
+ },
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
+ },
165
+ };
166
+ /**
167
+ * Whether the binding cookie is issued with `Secure`.
168
+ *
169
+ * Derived from the scheme of the provider's configured `redirectUri`, which
170
+ * is the deployment's own statement of the origin this cookie has to survive
171
+ * a round trip to.
172
+ *
173
+ * Not `req.secure`: express derives that from the socket unless `trust proxy`
174
+ * is on, and `@stonyx/rest-server` leaves it off by default, so in the
175
+ * standard production topology — TLS terminated at a proxy, plaintext to the
176
+ * origin — `req.secure` is `false` on every request to an HTTPS site and the
177
+ * cookie would ship without `Secure` while the deployment looks correct. Not
178
+ * the `Host` header either: that is attacker-controllable on any non-browser
179
+ * client. And not hardcoded `true`, which breaks plaintext local development.
180
+ *
181
+ * An unparseable or absent redirect URI fails secure.
182
+ */
183
+ isSecureContext(providerName) {
184
+ const redirectUri = this.oauth.redirectUriFor(providerName);
185
+ if (!redirectUri)
186
+ return true;
187
+ try {
188
+ return new URL(redirectUri).protocol !== 'http:';
189
+ }
190
+ catch {
191
+ return true;
192
+ }
193
+ }
194
+ cookieOptions(providerName) {
195
+ return {
196
+ httpOnly: true,
197
+ sameSite: STATE_COOKIE_SAME_SITE,
198
+ path: STATE_COOKIE_PATH,
199
+ secure: this.isSecureContext(providerName),
200
+ };
201
+ }
202
+ setBindingCookie(req, providerName, bindingValue) {
203
+ const { res } = req;
204
+ if (typeof res?.cookie !== 'function') {
205
+ log.error('OAuth: unable to set the state binding cookie; login rejected');
206
+ return false;
207
+ }
208
+ res.cookie(STATE_COOKIE_NAME, bindingValue, {
209
+ ...this.cookieOptions(providerName),
210
+ maxAge: this.oauth.stateTtl,
211
+ });
212
+ return true;
213
+ }
214
+ /**
215
+ * Every value the client presented under the binding cookie's name.
216
+ *
217
+ * Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
218
+ * either would hand an attacker a permanent, unauthenticated denial of login
219
+ * for any victim they can plant a same-named cookie on.
220
+ */
221
+ readBindingCookies(req) {
222
+ const header = req.headers.cookie;
223
+ if (!header)
224
+ return [];
225
+ const values = [];
226
+ for (const part of header.split(';')) {
227
+ const separator = part.indexOf('=');
228
+ if (separator === -1)
229
+ continue;
230
+ if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
231
+ continue;
232
+ // Not decoded. The binding value is base64url, whose alphabet
233
+ // `encodeURIComponent` never escapes, so decoding buys nothing — and
234
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
235
+ // unauthenticated caller can supply, turning the first line of the
236
+ // callback into a 500.
237
+ values.push(part.slice(separator + 1).trim());
238
+ }
239
+ return values;
240
+ }
241
+ clearBindingCookie(req, providerName) {
242
+ const { res } = req;
243
+ if (typeof res?.clearCookie !== 'function')
244
+ return;
245
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
246
+ }
247
+ }