@stonyx/oauth 0.1.1-alpha.26 → 0.1.1-alpha.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,8 @@ The module self-registers the following routes on the rest server:
56
56
  |--------|-------|-------------|
57
57
  | `GET` | `/auth` | Validate session — send `session-id` header, returns user or 401 |
58
58
  | `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page |
59
- | `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session |
59
+ | `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session, redirects with a single-use `ticket` |
60
+ | `POST` | `/auth/session` | Redeems the `ticket` for the session id — `application/json`, `{ "ticket": "..." }` |
60
61
  | `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
61
62
 
62
63
  ## Officially Supported Providers
@@ -184,11 +185,95 @@ The cookie name is fixed and its `Path` is `/`, so a second login started in the
184
185
 
185
186
  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
187
 
188
+ ## Session delivery — the exchange ticket
189
+
190
+ ### Breaking changes
191
+
192
+ **As of the fix for [#45](https://github.com/abofs/stonyx-oauth/issues/45).** This is a break in the **HTTP contract**, not in the JS API.
193
+
194
+ `GET /auth/callback/:provider` no longer redirects with `?sessionId=`. It redirects with a single-use, 60-second `?ticket=`, which is exchanged for the session id over a JSON `POST`:
195
+
196
+ ```
197
+ GET /auth/callback/:provider -> 302 <frontendCallbackUrl>?ticket=<opaque>&expiresAt=<ts>
198
+ POST /auth/session <- {"ticket":"<opaque>"} Content-Type: application/json
199
+ -> 200 {"sessionId":"<uuid>","expiresAt":<ts>}
200
+ -> 400 on an unknown, spent, expired or unparseable ticket
201
+ ```
202
+
203
+ **Who this breaks, and how:**
204
+
205
+ | Party | What breaks |
206
+ |---|---|
207
+ | **Any client reading `?sessionId=` off the callback redirect** | Gets `undefined`. The redirect no longer carries a session id under any name. |
208
+ | **Any client that cannot issue a cross-origin `POST`** | Cannot complete a login at all. The exchange is the only way to obtain a session id when `frontendCallbackUrl` is configured. |
209
+ | **Form-encoded callers** | `@stonyx/rest-server` installs `express.json()` only, so a form-encoded body arrives unparsed and the exchange returns `400`. The request **must** be `application/json`. |
210
+ | [`abofs/stonyx-dashboard`](https://github.com/abofs/stonyx-dashboard) | `demo-app/routes/auth/discord-callback.js` reads `?sessionId=`. Tracked at [stonyx-dashboard#103](https://github.com/abofs/stonyx-dashboard/issues/103), which must land before that consumer bumps. |
211
+ | `lynxury/backend` | `test/integration/05-oauth-bypass-test.js` reads `?sessionId=` from the callback redirect. Reds when it bumps off `@stonyx/oauth@0.1.1-beta.157`. |
212
+ | `lynxury/dashboard` | Bumps its `@stonyx/dashboard` commit pin after #103 lands. |
213
+
214
+ **Unaffected.** The `session-id` **header** contract is unchanged: `GET /auth` and `GET /auth/logout` still authenticate from it, and everything in the [`oauth_state` binding](#login-csrf-protection--the-oauth_state-cookie) is untouched. What changed is how the session id is *delivered once*, not how it is *used afterwards*. The JS API is unchanged — `handleCallback` still returns `{ sessionId, expiresAt }`, and a deployment with **no** `frontendCallbackUrl` configured still gets the session object as the callback's response body, because that is a direct response rather than a value written into a URL.
215
+
216
+ ### Why
217
+
218
+ 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:
219
+
220
+ - browser history and the address bar,
221
+ - the `Referer` header on any outbound link from the landing page,
222
+ - proxy, CDN and server access logs,
223
+ - `location.search`, readable by every script on the landing page.
224
+
225
+ The first and last of those are the app's own to close and nothing in front of the app can close them: no proxy can unwrite a URL the app chose. `Referrer-Policy` and log scrubbing belong to the frontend and to infrastructure respectively, and this change does not attempt them.
226
+
227
+ ### Ticket properties
228
+
229
+ | Property | Value | Why |
230
+ |---|---|---|
231
+ | Lifetime | **60 seconds** | One redirect plus one page load. Two orders of magnitude tighter than the 600s state TTL, because unlike the state this value travels in a URL. |
232
+ | Uses | **exactly one** | Consumed on recognition, before the TTL is checked, so every ticket gets one attempt whatever the outcome and the route is not a repeatable oracle. |
233
+ | Entropy | 32 random bytes, base64url | Independent of the session id, never derived from it. |
234
+ | 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`. |
235
+ | Failure modes | one indistinguishable `400` | Unknown, spent, expired and unparseable are not told apart. |
236
+
237
+ ### Known residual risk
238
+
239
+ **This is a reduction, not an elimination.** A ticket observed in the sub-second window *before* the landing page redeems it is redeemable by the observer. What the change buys is the difference between a live 24-hour credential permanently written into history and a one-shot token that is already spent by the time the page renders.
240
+
241
+ Closing the window means binding the ticket to the client that started the flow, the way [#36](https://github.com/abofs/stonyx-oauth/issues/36) bound the `state`. That binding has to travel on a cookie, and the exchange is cross-origin, so the cookie cannot be sent without `credentials: 'include'` — which needs [`abofs/stonyx-rest-server#45`](https://github.com/abofs/stonyx-rest-server/issues/45) plus a CORS change, since `origin` defaults to `*` and `*` with credentials is spec-forbidden. **That risk belongs to the rest-server layer.** Revisit when it lands.
242
+
243
+ An abandoned ticket is never garbage-collected, the same pre-existing limitation `pendingStates` has. It is bounded by a 60-second TTL rather than a 600-second one.
244
+
245
+ ### Migration
246
+
247
+ Read the `ticket`, exchange it, and scrub the URL:
248
+
249
+ ```javascript
250
+ // On the landing page at your `frontendCallbackUrl`, before first paint.
251
+ const params = new URLSearchParams(location.search);
252
+ const ticket = params.get('ticket');
253
+
254
+ const response = await fetch(`${host}/auth/session`, {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' }, // form-encoded will 400
257
+ body: JSON.stringify({ ticket }),
258
+ });
259
+
260
+ if (!response.ok) throw new Error('login failed'); // unknown, spent or expired
261
+
262
+ const { sessionId, expiresAt } = await response.json();
263
+
264
+ // The ticket is spent, but do not leave it in the address bar or in history.
265
+ history.replaceState({}, '', location.pathname);
266
+ ```
267
+
268
+ Then send `sessionId` as a `session-id` header exactly as before.
269
+
270
+ 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.
271
+
187
272
  ## Session Management
188
273
 
189
274
  Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
190
275
 
191
- Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
276
+ 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
277
 
193
278
  ## License
194
279
 
@@ -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 {
@@ -45,6 +53,14 @@ interface RouteRequest {
45
53
  headers: Record<string, string | undefined>;
46
54
  params: Record<string, string>;
47
55
  query: Record<string, string>;
56
+ /**
57
+ * Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
58
+ *
59
+ * Optional and typed loosely because it is whatever an unauthenticated
60
+ * caller sent: a form-encoded body arrives as `null` and a bodyless request
61
+ * as `undefined`, so every read of it has to survive both.
62
+ */
63
+ body?: unknown;
48
64
  res?: ResponseLike;
49
65
  }
50
66
  interface RouteState {
@@ -63,6 +79,25 @@ export default class AuthRequest extends Request {
63
79
  } | 500 | 400 | undefined>;
64
80
  '/logout': ({ headers }: RouteRequest) => void;
65
81
  };
82
+ post: {
83
+ /**
84
+ * Redeems the exchange ticket from the callback redirect (#45).
85
+ *
86
+ * `POST` and not `GET` because a `GET` would put the ticket back in a
87
+ * URL — in the caller's history, in access logs — which is the defect
88
+ * this route exists to close.
89
+ *
90
+ * `application/json` and not form-encoded: `@stonyx/rest-server`
91
+ * installs `express.json()` only, so a form-encoded body arrives as
92
+ * `null` and the ticket is unreadable. Measured, not assumed.
93
+ *
94
+ * Unknown, spent and expired tickets are one indistinguishable `400`.
95
+ */
96
+ '/session': ({ body }: RouteRequest) => 400 | {
97
+ sessionId: string;
98
+ expiresAt: number;
99
+ };
100
+ };
66
101
  };
67
102
  /**
68
103
  * Whether the binding cookie is issued with `Secure`.
@@ -79,13 +79,26 @@ 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
+ // `expiresAt` stays: it is not a credential and nothing
91
+ // authenticates from it.
82
92
  const params = new URLSearchParams({
83
- sessionId: session.sessionId,
93
+ ticket: this.oauth.issueExchangeTicket(session),
84
94
  expiresAt: String(session.expiresAt),
85
95
  });
86
96
  state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
87
97
  return;
88
98
  }
99
+ // No `frontendCallbackUrl` configured: the session is the response
100
+ // body of a direct request, not a value handed to a browser through
101
+ // a URL, so there is nothing here for #45 to fix.
89
102
  return session;
90
103
  }
91
104
  catch {
@@ -101,7 +114,31 @@ export default class AuthRequest extends Request {
101
114
  if (sessionId)
102
115
  this.oauth.logout(sessionId);
103
116
  },
104
- }
117
+ },
118
+ post: {
119
+ /**
120
+ * Redeems the exchange ticket from the callback redirect (#45).
121
+ *
122
+ * `POST` and not `GET` because a `GET` would put the ticket back in a
123
+ * URL — in the caller's history, in access logs — which is the defect
124
+ * this route exists to close.
125
+ *
126
+ * `application/json` and not form-encoded: `@stonyx/rest-server`
127
+ * installs `express.json()` only, so a form-encoded body arrives as
128
+ * `null` and the ticket is unreadable. Measured, not assumed.
129
+ *
130
+ * Unknown, spent and expired tickets are one indistinguishable `400`.
131
+ */
132
+ '/session': ({ body }) => {
133
+ const ticket = body?.ticket;
134
+ if (typeof ticket !== 'string' || !ticket)
135
+ return 400;
136
+ const session = this.oauth.redeemExchangeTicket(ticket);
137
+ if (!session)
138
+ return 400;
139
+ return { sessionId: session.sessionId, expiresAt: session.expiresAt };
140
+ },
141
+ },
105
142
  };
106
143
  /**
107
144
  * 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<import("./session-manager.js").SessionResult>;
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,67 @@
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.
8
+ */
9
+ export declare const TICKET_TTL_MS: number;
10
+ /** Entropy of a ticket, in bytes. */
11
+ export declare const TICKET_BYTES = 32;
12
+ interface TicketRecord {
13
+ sessionId: string;
14
+ expiresAt: number;
15
+ createdAt: number;
16
+ }
17
+ export interface RedeemedTicket {
18
+ sessionId: string;
19
+ expiresAt: number;
20
+ }
21
+ /**
22
+ * Single-use, short-lived tickets that stand in for a session id on the wire.
23
+ *
24
+ * The callback redirect hands the browser a ticket instead of the session id
25
+ * (#45). The ticket authenticates nothing — `GET /auth` reads the `session-id`
26
+ * header and knows only about `SessionManager` — so a ticket observed in
27
+ * history, in a `Referer`, or by a script reading `location.search` is worth
28
+ * something only inside the sub-second window before the landing page redeems
29
+ * it, and nothing at all afterwards.
30
+ *
31
+ * Known residual, stated rather than papered over: a ticket observed *within*
32
+ * that window is redeemable by the observer. Closing it means binding the
33
+ * ticket to the client the way #36 bound the state, and that binding has to
34
+ * travel on a cookie the cross-origin exchange cannot carry today — see
35
+ * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
36
+ *
37
+ * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
38
+ * a pre-existing pattern in this module, not something this store introduces,
39
+ * and it is bounded here by a 60-second TTL rather than a 10-minute one.
40
+ */
41
+ export default class TicketStore {
42
+ tickets: Map<string, TicketRecord>;
43
+ ttl: number;
44
+ /**
45
+ * Mints a ticket for a freshly created session.
46
+ *
47
+ * The ticket is independent entropy, never a transform of the session id:
48
+ * anything derived from the credential is the credential.
49
+ */
50
+ issue(sessionId: string, expiresAt: number): string;
51
+ /**
52
+ * Spends a ticket, if it is live.
53
+ *
54
+ * Consumed on recognition, *before* the TTL check, for the same reason
55
+ * `OAuth.handleCallback` consumes a pending state before validating its
56
+ * binding: every ticket gets exactly one attempt whatever the outcome, so
57
+ * this endpoint is never a repeatable oracle. Deleting after the TTL check
58
+ * instead would leave an expired ticket in the map answering `400` forever
59
+ * while a live one answers `200` — an unauthenticated distinguisher.
60
+ *
61
+ * Returns `null` for unknown, spent and expired tickets alike. The caller
62
+ * maps all three to the same `400`; telling them apart is information the
63
+ * holder of a ticket they did not mint has no business having.
64
+ */
65
+ redeem(ticket: string): RedeemedTicket | null;
66
+ }
67
+ export {};
@@ -0,0 +1,70 @@
1
+ import { 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.
9
+ */
10
+ export const TICKET_TTL_MS = 60 * 1000;
11
+ /** Entropy of a ticket, in bytes. */
12
+ export const TICKET_BYTES = 32;
13
+ /**
14
+ * Single-use, short-lived tickets that stand in for a session id on the wire.
15
+ *
16
+ * The callback redirect hands the browser a ticket instead of the session id
17
+ * (#45). The ticket authenticates nothing — `GET /auth` reads the `session-id`
18
+ * header and knows only about `SessionManager` — so a ticket observed in
19
+ * history, in a `Referer`, or by a script reading `location.search` is worth
20
+ * something only inside the sub-second window before the landing page redeems
21
+ * it, and nothing at all afterwards.
22
+ *
23
+ * Known residual, stated rather than papered over: a ticket observed *within*
24
+ * that window is redeemable by the observer. Closing it means binding the
25
+ * ticket to the client the way #36 bound the state, and that binding has to
26
+ * travel on a cookie the cross-origin exchange cannot carry today — see
27
+ * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
28
+ *
29
+ * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
30
+ * a pre-existing pattern in this module, not something this store introduces,
31
+ * and it is bounded here by a 60-second TTL rather than a 10-minute one.
32
+ */
33
+ export default class TicketStore {
34
+ tickets = new Map();
35
+ ttl = TICKET_TTL_MS;
36
+ /**
37
+ * Mints a ticket for a freshly created session.
38
+ *
39
+ * The ticket is independent entropy, never a transform of the session id:
40
+ * anything derived from the credential is the credential.
41
+ */
42
+ issue(sessionId, expiresAt) {
43
+ const ticket = randomBytes(TICKET_BYTES).toString('base64url');
44
+ this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
45
+ return ticket;
46
+ }
47
+ /**
48
+ * Spends a ticket, if it is live.
49
+ *
50
+ * Consumed on recognition, *before* the TTL check, for the same reason
51
+ * `OAuth.handleCallback` consumes a pending state before validating its
52
+ * binding: every ticket gets exactly one attempt whatever the outcome, so
53
+ * this endpoint is never a repeatable oracle. Deleting after the TTL check
54
+ * instead would leave an expired ticket in the map answering `400` forever
55
+ * while a live one answers `200` — an unauthenticated distinguisher.
56
+ *
57
+ * Returns `null` for unknown, spent and expired tickets alike. The caller
58
+ * maps all three to the same `400`; telling them apart is information the
59
+ * holder of a ticket they did not mint has no business having.
60
+ */
61
+ redeem(ticket) {
62
+ const record = ticket ? this.tickets.get(ticket) : undefined;
63
+ if (!record)
64
+ return null;
65
+ this.tickets.delete(ticket);
66
+ if (Date.now() - record.createdAt > this.ttl)
67
+ return null;
68
+ return { sessionId: record.sessionId, expiresAt: record.expiresAt };
69
+ }
70
+ }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.26",
7
+ "version": "0.1.1-alpha.27",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -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
 
@@ -73,6 +75,14 @@ interface RouteRequest {
73
75
  headers: Record<string, string | undefined>;
74
76
  params: Record<string, string>;
75
77
  query: Record<string, string>;
78
+ /**
79
+ * Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
80
+ *
81
+ * Optional and typed loosely because it is whatever an unauthenticated
82
+ * caller sent: a form-encoded body arrives as `null` and a bodyless request
83
+ * as `undefined`, so every read of it has to survive both.
84
+ */
85
+ body?: unknown;
76
86
  res?: ResponseLike;
77
87
  }
78
88
 
@@ -155,14 +165,27 @@ export default class AuthRequest extends Request {
155
165
  this.clearBindingCookie(req, providerName);
156
166
 
157
167
  if (this.oauth.frontendCallbackUrl) {
168
+ // The session id is the bearer credential (`GET /auth` above
169
+ // authenticates from exactly this value), so it must not be
170
+ // written into a URL: URLs land in browser history, in `Referer`
171
+ // on any outbound link, in proxy and CDN access logs, and in
172
+ // `location.search` for every script on the landing page. What
173
+ // goes in the URL instead is a single-use 60-second ticket that
174
+ // authenticates nothing, redeemed at `POST /auth/session` (#45).
175
+ //
176
+ // `expiresAt` stays: it is not a credential and nothing
177
+ // authenticates from it.
158
178
  const params = new URLSearchParams({
159
- sessionId: session.sessionId,
179
+ ticket: this.oauth.issueExchangeTicket(session),
160
180
  expiresAt: String(session.expiresAt),
161
181
  });
162
182
  state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
163
183
  return;
164
184
  }
165
185
 
186
+ // No `frontendCallbackUrl` configured: the session is the response
187
+ // body of a direct request, not a value handed to a browser through
188
+ // a URL, so there is nothing here for #45 to fix.
166
189
  return session;
167
190
  } catch {
168
191
  if (this.oauth.frontendCallbackUrl) {
@@ -177,7 +200,32 @@ export default class AuthRequest extends Request {
177
200
  const sessionId = headers['session-id'];
178
201
  if (sessionId) this.oauth.logout(sessionId);
179
202
  },
180
- }
203
+ },
204
+
205
+ post: {
206
+ /**
207
+ * Redeems the exchange ticket from the callback redirect (#45).
208
+ *
209
+ * `POST` and not `GET` because a `GET` would put the ticket back in a
210
+ * URL — in the caller's history, in access logs — which is the defect
211
+ * this route exists to close.
212
+ *
213
+ * `application/json` and not form-encoded: `@stonyx/rest-server`
214
+ * installs `express.json()` only, so a form-encoded body arrives as
215
+ * `null` and the ticket is unreadable. Measured, not assumed.
216
+ *
217
+ * Unknown, spent and expired tickets are one indistinguishable `400`.
218
+ */
219
+ '/session': ({ body }: RouteRequest) => {
220
+ const ticket = (body as { ticket?: unknown } | null | undefined)?.ticket;
221
+ if (typeof ticket !== 'string' || !ticket) return 400;
222
+
223
+ const session = this.oauth.redeemExchangeTicket(ticket);
224
+ if (!session) return 400;
225
+
226
+ return { sessionId: session.sessionId, expiresAt: session.expiresAt };
227
+ },
228
+ },
181
229
  };
182
230
 
183
231
  /**
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,87 @@
1
+ import { 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.
10
+ */
11
+ export const TICKET_TTL_MS = 60 * 1000;
12
+
13
+ /** Entropy of a ticket, in bytes. */
14
+ export const TICKET_BYTES = 32;
15
+
16
+ interface TicketRecord {
17
+ sessionId: string;
18
+ expiresAt: number;
19
+ createdAt: number;
20
+ }
21
+
22
+ export interface RedeemedTicket {
23
+ sessionId: string;
24
+ expiresAt: number;
25
+ }
26
+
27
+ /**
28
+ * Single-use, short-lived tickets that stand in for a session id on the wire.
29
+ *
30
+ * The callback redirect hands the browser a ticket instead of the session id
31
+ * (#45). The ticket authenticates nothing — `GET /auth` reads the `session-id`
32
+ * header and knows only about `SessionManager` — so a ticket observed in
33
+ * history, in a `Referer`, or by a script reading `location.search` is worth
34
+ * something only inside the sub-second window before the landing page redeems
35
+ * it, and nothing at all afterwards.
36
+ *
37
+ * Known residual, stated rather than papered over: a ticket observed *within*
38
+ * that window is redeemable by the observer. Closing it means binding the
39
+ * ticket to the client the way #36 bound the state, and that binding has to
40
+ * travel on a cookie the cross-origin exchange cannot carry today — see
41
+ * `abofs/stonyx-rest-server#45`. It is a reduction, not an elimination.
42
+ *
43
+ * Like `OAuth.pendingStates`, an abandoned ticket is never collected. That is
44
+ * a pre-existing pattern in this module, not something this store introduces,
45
+ * and it is bounded here by a 60-second TTL rather than a 10-minute one.
46
+ */
47
+ export default class TicketStore {
48
+ tickets = new Map<string, TicketRecord>();
49
+ ttl = TICKET_TTL_MS;
50
+
51
+ /**
52
+ * Mints a ticket for a freshly created session.
53
+ *
54
+ * The ticket is independent entropy, never a transform of the session id:
55
+ * anything derived from the credential is the credential.
56
+ */
57
+ issue(sessionId: string, expiresAt: number): string {
58
+ const ticket = randomBytes(TICKET_BYTES).toString('base64url');
59
+ this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
60
+ return ticket;
61
+ }
62
+
63
+ /**
64
+ * Spends a ticket, if it is live.
65
+ *
66
+ * Consumed on recognition, *before* the TTL check, for the same reason
67
+ * `OAuth.handleCallback` consumes a pending state before validating its
68
+ * binding: every ticket gets exactly one attempt whatever the outcome, so
69
+ * this endpoint is never a repeatable oracle. Deleting after the TTL check
70
+ * instead would leave an expired ticket in the map answering `400` forever
71
+ * while a live one answers `200` — an unauthenticated distinguisher.
72
+ *
73
+ * Returns `null` for unknown, spent and expired tickets alike. The caller
74
+ * maps all three to the same `400`; telling them apart is information the
75
+ * holder of a ticket they did not mint has no business having.
76
+ */
77
+ redeem(ticket: string): RedeemedTicket | null {
78
+ const record = ticket ? this.tickets.get(ticket) : undefined;
79
+ if (!record) return null;
80
+
81
+ this.tickets.delete(ticket);
82
+
83
+ if (Date.now() - record.createdAt > this.ttl) return null;
84
+
85
+ return { sessionId: record.sessionId, expiresAt: record.expiresAt };
86
+ }
87
+ }