@stonyx/oauth 0.1.1-alpha.26 → 0.1.1-alpha.28
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 +87 -2
- package/dist/auth-request.d.ts +35 -0
- package/dist/auth-request.js +39 -2
- package/dist/main.d.ts +16 -1
- package/dist/main.js +17 -0
- package/dist/ticket-store.d.ts +67 -0
- package/dist/ticket-store.js +70 -0
- package/package.json +4 -4
- package/src/auth-request.ts +50 -2
- package/src/main.ts +21 -0
- package/src/ticket-store.ts +87 -0
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
|
|
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
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -15,6 +15,14 @@ interface OAuthInstance {
|
|
|
15
15
|
sessionId: string;
|
|
16
16
|
expiresAt: number;
|
|
17
17
|
}>;
|
|
18
|
+
issueExchangeTicket(session: {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}): string;
|
|
22
|
+
redeemExchangeTicket(ticket: string): {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
} | null;
|
|
18
26
|
logout(sessionId: string): void;
|
|
19
27
|
}
|
|
20
28
|
export interface CookieOptions {
|
|
@@ -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`.
|
package/dist/auth-request.js
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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.
|
|
7
|
+
"version": "0.1.1-alpha.28",
|
|
8
8
|
"description": "OAuth2 authentication module for the Stonyx framework",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -54,14 +54,14 @@
|
|
|
54
54
|
"provenance": true
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@stonyx/events": "0.1.1-beta.
|
|
58
|
-
"stonyx": "0.2.3-beta.
|
|
57
|
+
"@stonyx/events": "0.1.1-beta.54",
|
|
58
|
+
"stonyx": "0.2.3-beta.82"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.100",
|
|
65
65
|
"@stonyx/utils": "0.2.3-beta.26",
|
|
66
66
|
"@stonyx/logs": "1.0.1-beta.20",
|
|
67
67
|
"@types/qunit": "^2.19.13",
|
package/src/auth-request.ts
CHANGED
|
@@ -40,6 +40,8 @@ interface OAuthInstance {
|
|
|
40
40
|
stateToken: string,
|
|
41
41
|
bindingValues: readonly string[],
|
|
42
42
|
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
43
|
+
issueExchangeTicket(session: { sessionId: string; expiresAt: number }): string;
|
|
44
|
+
redeemExchangeTicket(ticket: string): { sessionId: string; expiresAt: number } | null;
|
|
43
45
|
logout(sessionId: string): void;
|
|
44
46
|
}
|
|
45
47
|
|
|
@@ -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
|
-
|
|
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
|
+
}
|