@stonyx/oauth 0.1.1-alpha.31 → 0.1.1-alpha.33
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 +3 -0
- package/dist/auth-request.d.ts +16 -1
- package/dist/auth-request.js +13 -1
- package/dist/ticket-store.d.ts +16 -0
- package/dist/ticket-store.js +23 -4
- package/package.json +1 -1
- package/src/auth-request.ts +19 -1
- package/src/ticket-store.ts +24 -4
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 | **SHA-256 digest only** | The store is keyed by the digest of the ticket, never by the ticket, so a heap dump or an accidental log of the map yields a useless digest rather than a live redeemable credential. Same discipline as the `oauth_state` binding, which stores `bindingHash` and never the binding value. 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
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -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': (
|
|
111
|
+
'/session': (req: RouteRequest) => 400 | {
|
|
97
112
|
sessionId: string;
|
|
98
113
|
expiresAt: number;
|
|
99
114
|
};
|
package/dist/auth-request.js
CHANGED
|
@@ -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': (
|
|
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;
|
package/dist/ticket-store.d.ts
CHANGED
|
@@ -75,8 +75,24 @@ export interface RedeemedTicket {
|
|
|
75
75
|
* The extraction is tracked at `abofs/stonyx-oauth#58`.
|
|
76
76
|
*/
|
|
77
77
|
export default class TicketStore {
|
|
78
|
+
/**
|
|
79
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
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.
|
|
86
|
+
*
|
|
87
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
88
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
89
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
90
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
91
|
+
*/
|
|
78
92
|
tickets: Map<string, TicketRecord>;
|
|
79
93
|
ttl: number;
|
|
94
|
+
/** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
|
|
95
|
+
static hash(ticket: string): string;
|
|
80
96
|
/**
|
|
81
97
|
* Mints a ticket for a freshly created session.
|
|
82
98
|
*
|
package/dist/ticket-store.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
/**
|
|
3
3
|
* Lifetime of an exchange ticket.
|
|
4
4
|
*
|
|
@@ -67,8 +67,26 @@ export const TICKET_BYTES = 32;
|
|
|
67
67
|
* The extraction is tracked at `abofs/stonyx-oauth#58`.
|
|
68
68
|
*/
|
|
69
69
|
export default class TicketStore {
|
|
70
|
+
/**
|
|
71
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
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.
|
|
78
|
+
*
|
|
79
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
80
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
81
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
82
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
83
|
+
*/
|
|
70
84
|
tickets = new Map();
|
|
71
85
|
ttl = TICKET_TTL_MS;
|
|
86
|
+
/** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
|
|
87
|
+
static hash(ticket) {
|
|
88
|
+
return createHash('sha256').update(ticket).digest('hex');
|
|
89
|
+
}
|
|
72
90
|
/**
|
|
73
91
|
* Mints a ticket for a freshly created session.
|
|
74
92
|
*
|
|
@@ -77,7 +95,7 @@ export default class TicketStore {
|
|
|
77
95
|
*/
|
|
78
96
|
issue(sessionId, expiresAt) {
|
|
79
97
|
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
80
|
-
this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
|
|
98
|
+
this.tickets.set(TicketStore.hash(ticket), { sessionId, expiresAt, createdAt: Date.now() });
|
|
81
99
|
return ticket;
|
|
82
100
|
}
|
|
83
101
|
/**
|
|
@@ -95,10 +113,11 @@ export default class TicketStore {
|
|
|
95
113
|
* holder of a ticket they did not mint has no business having.
|
|
96
114
|
*/
|
|
97
115
|
redeem(ticket) {
|
|
98
|
-
const
|
|
116
|
+
const key = ticket ? TicketStore.hash(ticket) : null;
|
|
117
|
+
const record = key ? this.tickets.get(key) : undefined;
|
|
99
118
|
if (!record)
|
|
100
119
|
return null;
|
|
101
|
-
this.tickets.delete(
|
|
120
|
+
this.tickets.delete(key);
|
|
102
121
|
if (Date.now() - record.createdAt > this.ttl)
|
|
103
122
|
return null;
|
|
104
123
|
return { sessionId: record.sessionId, expiresAt: record.expiresAt };
|
package/package.json
CHANGED
package/src/auth-request.ts
CHANGED
|
@@ -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': (
|
|
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
|
|
package/src/ticket-store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Lifetime of an exchange ticket.
|
|
@@ -81,9 +81,28 @@ export interface RedeemedTicket {
|
|
|
81
81
|
* The extraction is tracked at `abofs/stonyx-oauth#58`.
|
|
82
82
|
*/
|
|
83
83
|
export default class TicketStore {
|
|
84
|
+
/**
|
|
85
|
+
* Live tickets, keyed by the **SHA-256 of the ticket**, never by the ticket.
|
|
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.
|
|
92
|
+
*
|
|
93
|
+
* No constant-time comparison is needed and none is used: lookup is a hash
|
|
94
|
+
* probe on a 256-bit high-entropy key, not a secret-dependent byte
|
|
95
|
+
* comparison, so there is no early-exit timing signal to exploit. That is
|
|
96
|
+
* the same reason `redeem` can stay an ordinary `Map.get`.
|
|
97
|
+
*/
|
|
84
98
|
tickets = new Map<string, TicketRecord>();
|
|
85
99
|
ttl = TICKET_TTL_MS;
|
|
86
100
|
|
|
101
|
+
/** SHA-256 of a ticket, hex — the only form this store keeps on the heap. */
|
|
102
|
+
static hash(ticket: string): string {
|
|
103
|
+
return createHash('sha256').update(ticket).digest('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
/**
|
|
88
107
|
* Mints a ticket for a freshly created session.
|
|
89
108
|
*
|
|
@@ -92,7 +111,7 @@ export default class TicketStore {
|
|
|
92
111
|
*/
|
|
93
112
|
issue(sessionId: string, expiresAt: number): string {
|
|
94
113
|
const ticket = randomBytes(TICKET_BYTES).toString('base64url');
|
|
95
|
-
this.tickets.set(ticket, { sessionId, expiresAt, createdAt: Date.now() });
|
|
114
|
+
this.tickets.set(TicketStore.hash(ticket), { sessionId, expiresAt, createdAt: Date.now() });
|
|
96
115
|
return ticket;
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -111,10 +130,11 @@ export default class TicketStore {
|
|
|
111
130
|
* holder of a ticket they did not mint has no business having.
|
|
112
131
|
*/
|
|
113
132
|
redeem(ticket: string): RedeemedTicket | null {
|
|
114
|
-
const
|
|
133
|
+
const key = ticket ? TicketStore.hash(ticket) : null;
|
|
134
|
+
const record = key ? this.tickets.get(key) : undefined;
|
|
115
135
|
if (!record) return null;
|
|
116
136
|
|
|
117
|
-
this.tickets.delete(
|
|
137
|
+
this.tickets.delete(key!);
|
|
118
138
|
|
|
119
139
|
if (Date.now() - record.createdAt > this.ttl) return null;
|
|
120
140
|
|