@stonyx/oauth 0.1.1-alpha.32 → 0.1.1-alpha.34

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
@@ -202,6 +202,7 @@ This fails closed — no session is minted for the wrong flow, and it is not a w
202
202
  GET /auth/callback/:provider -> 302 <frontendCallbackUrl>#ticket=<opaque>&expiresAt=<ts>
203
203
  POST /auth/session <- {"ticket":"<opaque>"} Content-Type: application/json
204
204
  -> 200 {"sessionId":"<uuid>","expiresAt":<ts>}
205
+ Cache-Control: no-store
205
206
  -> 400 on an unknown, spent, expired or unparseable ticket
206
207
  ```
207
208
 
@@ -256,6 +257,8 @@ What the fragment does **not** remove is browser history and readability by page
256
257
  | Entropy | 32 random bytes, base64url | Independent of the session id, never derived from it. |
257
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`. |
258
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. |
259
262
 
260
263
  ### Known residual risk
261
264
 
@@ -48,6 +48,12 @@ export interface CookieOptions {
48
48
  interface ResponseLike {
49
49
  cookie(name: string, value: string, options: CookieOptions): unknown;
50
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;
51
57
  }
52
58
  interface RouteRequest {
53
59
  headers: Record<string, string | undefined>;
@@ -92,8 +98,17 @@ export default class AuthRequest extends Request {
92
98
  * `null` and the ticket is unreadable. Measured, not assumed.
93
99
  *
94
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`).
95
110
  */
96
- '/session': ({ body }: RouteRequest) => 400 | {
111
+ '/session': (req: RouteRequest) => 400 | {
97
112
  sessionId: string;
98
113
  expiresAt: number;
99
114
  };
@@ -139,8 +139,20 @@ export default class AuthRequest extends Request {
139
139
  * `null` and the ticket is unreadable. Measured, not assumed.
140
140
  *
141
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`).
142
151
  */
143
- '/session': ({ body }) => {
152
+ '/session': (req) => {
153
+ const { body, res } = req;
154
+ if (typeof res?.setHeader === 'function')
155
+ res.setHeader('Cache-Control', 'no-store');
144
156
  const ticket = body?.ticket;
145
157
  if (typeof ticket !== 'string' || !ticket)
146
158
  return 400;
@@ -78,11 +78,26 @@ export default class TicketStore {
78
78
  /**
79
79
  * Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
80
80
  *
81
- * Same discipline as `OAuth.pendingStates`, which holds `bindingHash` and
82
- * never the binding value: a ticket is a client-presented secret looked up
83
- * server-side, so a heap dump, a debug serialisation or an accidental log of
84
- * this map should yield a useless digest rather than a live redeemable
85
- * credential. The two secret stores in this module now agree.
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.
86
101
  *
87
102
  * No constant-time comparison is needed and none is used: lookup is a hash
88
103
  * probe on a 256-bit high-entropy key, not a secret-dependent byte
@@ -91,7 +106,7 @@ export default class TicketStore {
91
106
  */
92
107
  tickets: Map<string, TicketRecord>;
93
108
  ttl: number;
94
- /** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
109
+ /** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
95
110
  static hash(ticket: string): string;
96
111
  /**
97
112
  * Mints a ticket for a freshly created session.
@@ -70,11 +70,26 @@ export default class TicketStore {
70
70
  /**
71
71
  * Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
72
72
  *
73
- * Same discipline as `OAuth.pendingStates`, which holds `bindingHash` and
74
- * never the binding value: a ticket is a client-presented secret looked up
75
- * server-side, so a heap dump, a debug serialisation or an accidental log of
76
- * this map should yield a useless digest rather than a live redeemable
77
- * credential. The two secret stores in this module now agree.
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.
78
93
  *
79
94
  * No constant-time comparison is needed and none is used: lookup is a hash
80
95
  * probe on a 256-bit high-entropy key, not a secret-dependent byte
@@ -83,7 +98,7 @@ export default class TicketStore {
83
98
  */
84
99
  tickets = new Map();
85
100
  ttl = TICKET_TTL_MS;
86
- /** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
101
+ /** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
87
102
  static hash(ticket) {
88
103
  return createHash('sha256').update(ticket).digest('hex');
89
104
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.32",
7
+ "version": "0.1.1-alpha.34",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -69,6 +69,12 @@ export interface CookieOptions {
69
69
  interface ResponseLike {
70
70
  cookie(name: string, value: string, options: CookieOptions): unknown;
71
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;
72
78
  }
73
79
 
74
80
  interface RouteRequest {
@@ -226,8 +232,20 @@ export default class AuthRequest extends Request {
226
232
  * `null` and the ticket is unreadable. Measured, not assumed.
227
233
  *
228
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`).
229
244
  */
230
- '/session': ({ body }: RouteRequest) => {
245
+ '/session': (req: RouteRequest) => {
246
+ const { body, res } = req;
247
+ if (typeof res?.setHeader === 'function') res.setHeader('Cache-Control', 'no-store');
248
+
231
249
  const ticket = (body as { ticket?: unknown } | null | undefined)?.ticket;
232
250
  if (typeof ticket !== 'string' || !ticket) return 400;
233
251
 
@@ -84,11 +84,26 @@ export default class TicketStore {
84
84
  /**
85
85
  * Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
86
86
  *
87
- * Same discipline as `OAuth.pendingStates`, which holds `bindingHash` and
88
- * never the binding value: a ticket is a client-presented secret looked up
89
- * server-side, so a heap dump, a debug serialisation or an accidental log of
90
- * this map should yield a useless digest rather than a live redeemable
91
- * credential. The two secret stores in this module now agree.
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.
92
107
  *
93
108
  * No constant-time comparison is needed and none is used: lookup is a hash
94
109
  * probe on a 256-bit high-entropy key, not a secret-dependent byte
@@ -98,7 +113,7 @@ export default class TicketStore {
98
113
  tickets = new Map<string, TicketRecord>();
99
114
  ttl = TICKET_TTL_MS;
100
115
 
101
- /** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
116
+ /** SHA-256 of a ticket, hex — the only form of the *ticket* this store keeps. */
102
117
  static hash(ticket: string): string {
103
118
  return createHash('sha256').update(ticket).digest('hex');
104
119
  }