@stonyx/oauth 0.1.1-alpha.2 → 0.1.1-alpha.21
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 +170 -3
- package/dist/auth-request.d.ts +147 -0
- package/dist/auth-request.js +342 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.js +42 -0
- package/dist/main.d.ts +45 -0
- package/dist/main.js +81 -0
- package/dist/oauth-flow.d.ts +30 -0
- package/dist/oauth-flow.js +83 -0
- package/dist/providers/discord.d.ts +30 -0
- package/dist/providers/discord.js +43 -0
- package/dist/session-manager.d.ts +20 -0
- package/dist/session-manager.js +30 -0
- package/dist/state-store.d.ts +116 -0
- package/dist/state-store.js +144 -0
- package/dist/token-manager.d.ts +15 -0
- package/dist/token-manager.js +24 -0
- package/package.json +45 -9
- package/src/auth-request.ts +434 -0
- package/src/constants.ts +46 -0
- package/src/main.ts +123 -0
- package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
- package/src/providers/{discord.js → discord.ts} +29 -3
- package/src/{session-manager.js → session-manager.ts} +19 -6
- package/src/state-store.ts +179 -0
- package/src/token-manager.ts +35 -0
- package/src/types/node.d.ts +10 -0
- package/src/types/stonyx-events.d.ts +4 -0
- package/src/types/stonyx-rest-server.d.ts +11 -0
- package/src/types/stonyx.d.ts +38 -0
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/src/auth-request.js +0 -74
- package/src/main.js +0 -83
- package/src/token-manager.js +0 -26
- package/test/config/environment.js +0 -18
- package/test/integration/oauth-test.js +0 -149
- package/test/sample/providers/mock.js +0 -40
- package/test/sample/requests/.gitkeep +0 -0
- package/test/unit/oauth-flow-test.js +0 -137
- package/test/unit/providers/discord-test.js +0 -115
- package/test/unit/session-manager-test.js +0 -85
- package/test/unit/state-validation-test.js +0 -118
- package/test/unit/token-manager-test.js +0 -76
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
import { StateRejection } from './state-store.js';
|
|
4
|
+
import {
|
|
5
|
+
STATE_COOKIE_NAME,
|
|
6
|
+
STATE_COOKIE_PATH,
|
|
7
|
+
STATE_COOKIE_SAME_SITE,
|
|
8
|
+
STATE_TTL_MS,
|
|
9
|
+
} from './constants.js';
|
|
10
|
+
|
|
11
|
+
interface AuthorizationRequest {
|
|
12
|
+
url: string;
|
|
13
|
+
bindingValue: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Hosts treated as a development origin by exact match, and — together with
|
|
18
|
+
* `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
|
|
19
|
+
* from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
|
|
20
|
+
*
|
|
21
|
+
* `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
|
|
22
|
+
* local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
|
|
23
|
+
* listed here, so the two are not silently redundant.
|
|
24
|
+
*/
|
|
25
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
|
|
26
|
+
|
|
27
|
+
/** `host` values whose port component is anything but a decimal port are rejected. */
|
|
28
|
+
const PORT_PATTERN = /^\d{1,5}$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The characters RFC 1123 permits in a registered hostname, plus `.`.
|
|
32
|
+
*
|
|
33
|
+
* Anything else — `@`, `,`, whitespace, `/` — means the value is not a bare
|
|
34
|
+
* hostname, and the caller fails secure rather than guessing. This is what
|
|
35
|
+
* rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
|
|
36
|
+
*/
|
|
37
|
+
const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
38
|
+
|
|
39
|
+
/** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
|
|
40
|
+
function isLoopbackIpv4(hostname: string): boolean {
|
|
41
|
+
const octets = hostname.split('.');
|
|
42
|
+
if (octets.length !== 4) return false;
|
|
43
|
+
if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)) return false;
|
|
44
|
+
|
|
45
|
+
return Number(octets[0]) === 127;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
|
|
50
|
+
* `::ffff:127.0.0.1` and `::ffff:7f00:1`.
|
|
51
|
+
*/
|
|
52
|
+
function isLoopbackIpv6(hostname: string): boolean {
|
|
53
|
+
const mapped = /^::ffff:(.+)$/.exec(hostname);
|
|
54
|
+
if (!mapped) return false;
|
|
55
|
+
|
|
56
|
+
const rest = mapped[1];
|
|
57
|
+
if (isLoopbackIpv4(rest)) return true;
|
|
58
|
+
|
|
59
|
+
const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
|
|
60
|
+
if (!hextets) return false;
|
|
61
|
+
|
|
62
|
+
return parseInt(hextets[1], 16) >>> 8 === 127;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface OAuthInstance {
|
|
66
|
+
frontendCallbackUrl?: string;
|
|
67
|
+
getSession(sessionId: string): unknown;
|
|
68
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
69
|
+
handleCallback(
|
|
70
|
+
providerName: string,
|
|
71
|
+
code: string,
|
|
72
|
+
stateToken: string,
|
|
73
|
+
bindingValues: readonly string[],
|
|
74
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
75
|
+
logout(sessionId: string): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CookieOptions {
|
|
79
|
+
httpOnly: boolean;
|
|
80
|
+
sameSite: string;
|
|
81
|
+
path: string;
|
|
82
|
+
secure: boolean;
|
|
83
|
+
maxAge?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The response object Express hangs off the request.
|
|
88
|
+
*
|
|
89
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state` has no
|
|
90
|
+
* affordance for response headers, so setting a cookie means reaching for
|
|
91
|
+
* `req.res`. This is a deliberate, temporary escape hatch — tracked by
|
|
92
|
+
* `abofs/stonyx-rest-server#45`, which adds a first-class header affordance to
|
|
93
|
+
* migrate onto.
|
|
94
|
+
*/
|
|
95
|
+
interface ResponseLike {
|
|
96
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
97
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface RouteRequest {
|
|
101
|
+
headers: Record<string, string | undefined>;
|
|
102
|
+
/**
|
|
103
|
+
* Node's flat `[name, value, name, value, ...]` header list, when the runtime
|
|
104
|
+
* supplies it. Read only to detect a *duplicate* `Host`: Node collapses
|
|
105
|
+
* repeats into the first value, so `req.headers.host` alone cannot tell an
|
|
106
|
+
* unambiguous origin from a smuggled one.
|
|
107
|
+
*/
|
|
108
|
+
rawHeaders?: string[];
|
|
109
|
+
params: Record<string, string>;
|
|
110
|
+
query: Record<string, string>;
|
|
111
|
+
secure?: boolean;
|
|
112
|
+
res?: ResponseLike;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RouteState {
|
|
116
|
+
redirect?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default class AuthRequest extends Request {
|
|
120
|
+
oauth: OAuthInstance;
|
|
121
|
+
|
|
122
|
+
constructor(oauth: OAuthInstance) {
|
|
123
|
+
super();
|
|
124
|
+
this.oauth = oauth;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
handlers = {
|
|
128
|
+
get: {
|
|
129
|
+
'/': ({ headers }: RouteRequest) => {
|
|
130
|
+
const sessionId = headers['session-id'];
|
|
131
|
+
if (!sessionId) return 401;
|
|
132
|
+
|
|
133
|
+
const user = this.oauth.getSession(sessionId);
|
|
134
|
+
if (!user) return 401;
|
|
135
|
+
|
|
136
|
+
return user;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
140
|
+
const { provider: providerName } = req.params;
|
|
141
|
+
|
|
142
|
+
let authorization: AuthorizationRequest;
|
|
143
|
+
try {
|
|
144
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
145
|
+
} catch {
|
|
146
|
+
return 404;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Fail closed: a state we cannot bind to this client is exactly the
|
|
150
|
+
// defect this mechanism exists to prevent, so never issue one.
|
|
151
|
+
if (!this.setBindingCookie(req, authorization.bindingValue)) return 500;
|
|
152
|
+
|
|
153
|
+
state.redirect = authorization.url;
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
157
|
+
const { provider: providerName } = req.params;
|
|
158
|
+
const { code, state: stateToken, error } = req.query;
|
|
159
|
+
|
|
160
|
+
const bindingValues = this.readBindingCookies(req);
|
|
161
|
+
|
|
162
|
+
if (error) {
|
|
163
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
164
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
return 400;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!code) return 400;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValues);
|
|
174
|
+
|
|
175
|
+
// The binding value is single-use and the state has now been
|
|
176
|
+
// consumed, so this is the end of that cookie's life.
|
|
177
|
+
this.clearBindingCookie(req);
|
|
178
|
+
|
|
179
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
180
|
+
const params = new URLSearchParams({
|
|
181
|
+
sessionId: session.sessionId,
|
|
182
|
+
expiresAt: String(session.expiresAt),
|
|
183
|
+
});
|
|
184
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return session;
|
|
189
|
+
} catch (rejection) {
|
|
190
|
+
// Clear only when this request actually spent the cookie.
|
|
191
|
+
//
|
|
192
|
+
// Moving the clear below the `error` and `!code` returns was not
|
|
193
|
+
// enough: it still ran unconditionally for any request carrying a
|
|
194
|
+
// `code`, and `code` is attacker-supplied and unvalidated. So
|
|
195
|
+
// `?code=1` — one query parameter, no knowledge of the victim's state
|
|
196
|
+
// — deleted the binding cookie of a client still at the provider's
|
|
197
|
+
// consent screen, leaving their pending state untouched so nothing
|
|
198
|
+
// was detectable server-side, and their real callback then failed.
|
|
199
|
+
//
|
|
200
|
+
// `StateRejection.consumed` is the only thing that distinguishes
|
|
201
|
+
// "nothing of this client's was touched" from "one attempt was
|
|
202
|
+
// spent". Anything that is not a `StateRejection` was thrown below
|
|
203
|
+
// the state check, which means the record was already burned.
|
|
204
|
+
if (!(rejection instanceof StateRejection) || rejection.consumed) {
|
|
205
|
+
this.clearBindingCookie(req);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// `StateStore.consume` distinguishes five rejection reasons that
|
|
209
|
+
// otherwise collapse into one opaque outcome with no server-side
|
|
210
|
+
// signal at all. The client-facing `auth_failed` stays opaque; the
|
|
211
|
+
// server has no reason to be.
|
|
212
|
+
//
|
|
213
|
+
// Only a `StateRejection`'s message is logged, and those are the
|
|
214
|
+
// fixed strings in `STATE_REJECTION`. The `try` above spans far more
|
|
215
|
+
// than `consume` — `getProvider`, `TokenManager.getTokens` ->
|
|
216
|
+
// `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
|
|
217
|
+
// `emit('authenticate')`, `sessionManager.create` — and three of
|
|
218
|
+
// those are consumer-overridable through the documented
|
|
219
|
+
// `providers.<name>.module` extension point. A provider that puts
|
|
220
|
+
// request context in its error, which is ordinary practice, would
|
|
221
|
+
// otherwise land its `clientSecret` and the caller-supplied `code` in
|
|
222
|
+
// the log verbatim; `@stonyx/logs` appends content raw when
|
|
223
|
+
// `logToFile` is enabled, so an echoed `code` is also a CRLF
|
|
224
|
+
// log-forging primitive for an unauthenticated caller. Before this
|
|
225
|
+
// module logged anything, all of that was swallowed.
|
|
226
|
+
//
|
|
227
|
+
// Anything below the state check therefore gets a fixed
|
|
228
|
+
// discriminator, and the detail is left to whatever the provider
|
|
229
|
+
// itself logs.
|
|
230
|
+
if (rejection instanceof StateRejection) {
|
|
231
|
+
log.error(`OAuth: callback rejected — ${rejection.message}`);
|
|
232
|
+
} else {
|
|
233
|
+
log.error('OAuth: callback failed after state validation');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
237
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
return 500;
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
'/logout': ({ headers }: RouteRequest) => {
|
|
245
|
+
const sessionId = headers['session-id'];
|
|
246
|
+
if (sessionId) this.oauth.logout(sessionId);
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'> {
|
|
252
|
+
return {
|
|
253
|
+
httpOnly: true,
|
|
254
|
+
// Load-bearing: the callback is a cross-site top-level GET navigation
|
|
255
|
+
// from the provider. `Strict` withholds the cookie on exactly that
|
|
256
|
+
// request and breaks login outright.
|
|
257
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
258
|
+
path: STATE_COOKIE_PATH,
|
|
259
|
+
secure: this.isSecureContext(req),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
265
|
+
*
|
|
266
|
+
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
267
|
+
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
268
|
+
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
269
|
+
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
270
|
+
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
271
|
+
* cookie would ship without `Secure` while the deployment looks correct.
|
|
272
|
+
*
|
|
273
|
+
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
274
|
+
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
275
|
+
* at the first login and is loud. The alternative fails silently, in
|
|
276
|
+
* production, on the one attribute protecting the value this whole mechanism
|
|
277
|
+
* is built around.
|
|
278
|
+
*
|
|
279
|
+
* The exemption is decided by *parsing* the `Host` header and testing the
|
|
280
|
+
* result for membership, never by matching a prefix or a suffix on the raw
|
|
281
|
+
* value — `Host` is attacker-controllable on any non-browser client, and a
|
|
282
|
+
* security predicate written as a substring match drifts. Every shape that
|
|
283
|
+
* cannot be parsed as a bare `host[:port]`, and every request with more than
|
|
284
|
+
* one `Host`, fails secure.
|
|
285
|
+
*/
|
|
286
|
+
isSecureContext(req: RouteRequest): boolean {
|
|
287
|
+
if (req.secure === true) return true;
|
|
288
|
+
if (AuthRequest.hasAmbiguousHost(req)) return true;
|
|
289
|
+
|
|
290
|
+
const host = req.headers.host;
|
|
291
|
+
if (!host) return true;
|
|
292
|
+
|
|
293
|
+
const hostname = AuthRequest.parseHostname(host);
|
|
294
|
+
if (hostname === undefined) return true;
|
|
295
|
+
|
|
296
|
+
return !AuthRequest.isLoopbackHost(hostname);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* True when the request carried more than one `Host` header.
|
|
301
|
+
*
|
|
302
|
+
* Node keeps the first and discards the rest, so a component that *prepends*
|
|
303
|
+
* a `Host:` line — request smuggling, or a proxy that appends rather than
|
|
304
|
+
* replaces — can make `req.headers.host` read `localhost` on a request whose
|
|
305
|
+
* real origin is public. RFC 9112 section 3.2 makes such a request invalid;
|
|
306
|
+
* this treats it as unattributable and fails secure rather than trusting it.
|
|
307
|
+
*/
|
|
308
|
+
static hasAmbiguousHost(req: RouteRequest): boolean {
|
|
309
|
+
const raw = req.rawHeaders;
|
|
310
|
+
if (!Array.isArray(raw)) return false;
|
|
311
|
+
|
|
312
|
+
let seen = 0;
|
|
313
|
+
for (let index = 0; index < raw.length; index += 2) {
|
|
314
|
+
if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host') seen++;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return seen > 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The hostname component of a `Host` header, lowercased, or `undefined` when
|
|
322
|
+
* the value is not a well-formed `host[:port]`.
|
|
323
|
+
*
|
|
324
|
+
* `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
|
|
325
|
+
* `localhost:80@evil.com` reduces to `localhost`. The port is therefore
|
|
326
|
+
* required to be decimal, and the hostname to contain only characters a
|
|
327
|
+
* registered name may contain.
|
|
328
|
+
*/
|
|
329
|
+
static parseHostname(host: string): string | undefined {
|
|
330
|
+
if (host.startsWith('[')) {
|
|
331
|
+
const close = host.indexOf(']');
|
|
332
|
+
if (close === -1) return undefined;
|
|
333
|
+
|
|
334
|
+
const port = host.slice(close + 1);
|
|
335
|
+
if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1)))) return undefined;
|
|
336
|
+
|
|
337
|
+
const literal = host.slice(1, close);
|
|
338
|
+
if (!/^[0-9A-Fa-f:.]+$/.test(literal)) return undefined;
|
|
339
|
+
|
|
340
|
+
return literal.toLowerCase();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const colon = host.indexOf(':');
|
|
344
|
+
if (colon === -1) return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
|
|
345
|
+
|
|
346
|
+
if (!PORT_PATTERN.test(host.slice(colon + 1))) return undefined;
|
|
347
|
+
|
|
348
|
+
const name = host.slice(0, colon);
|
|
349
|
+
|
|
350
|
+
return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether a parsed hostname is a loopback development origin.
|
|
355
|
+
*
|
|
356
|
+
* Membership tests, never prefix or suffix tests. `startsWith('127.')`
|
|
357
|
+
* matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
|
|
358
|
+
* leading digit in a label), and `endsWith('.localhost')` exempted an entire
|
|
359
|
+
* suffix — so a `.localhost` split-horizon vhost shipped the binding value in
|
|
360
|
+
* cleartext. The `.localhost` exemption is withdrawn rather than tightened:
|
|
361
|
+
* the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
|
|
362
|
+
* never documented it, and a developer on `app.localhost` reaches the same
|
|
363
|
+
* server on `localhost` or `127.0.0.1`.
|
|
364
|
+
*/
|
|
365
|
+
static isLoopbackHost(hostname: string): boolean {
|
|
366
|
+
if (LOOPBACK_HOSTS.has(hostname)) return true;
|
|
367
|
+
if (isLoopbackIpv4(hostname)) return true;
|
|
368
|
+
|
|
369
|
+
return isLoopbackIpv6(hostname);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
|
|
373
|
+
const { res } = req;
|
|
374
|
+
|
|
375
|
+
if (typeof res?.cookie !== 'function') {
|
|
376
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
381
|
+
...this.cookieOptions(req),
|
|
382
|
+
maxAge: STATE_TTL_MS,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Every value the client presented under the binding cookie's name.
|
|
390
|
+
*
|
|
391
|
+
* Not the first one. A browser sends every applicable cookie in a single
|
|
392
|
+
* header, and a sibling subdomain can set a same-named cookie on the parent
|
|
393
|
+
* domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
|
|
394
|
+
* returning on the first name match handed an attacker a permanent,
|
|
395
|
+
* unauthenticated denial of login for any victim they could plant a cookie
|
|
396
|
+
* on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
|
|
397
|
+
* is writing, not reading.
|
|
398
|
+
*
|
|
399
|
+
* Every value is returned, with no cap. A cap here does not bound an attack,
|
|
400
|
+
* it *is* one: truncating the list reinstates exactly the denial above its
|
|
401
|
+
* own threshold, because the planted cookies are the ones that sort first.
|
|
402
|
+
* The work is already bounded by Node's 16 KB header limit — at most 779
|
|
403
|
+
* hashable candidates, 0.32 ms to parse and hash all of them. See
|
|
404
|
+
* `constants.ts` for the measurement.
|
|
405
|
+
*/
|
|
406
|
+
readBindingCookies(req: RouteRequest): string[] {
|
|
407
|
+
const header = req.headers.cookie;
|
|
408
|
+
if (!header) return [];
|
|
409
|
+
|
|
410
|
+
const values: string[] = [];
|
|
411
|
+
|
|
412
|
+
for (const part of header.split(';')) {
|
|
413
|
+
const separator = part.indexOf('=');
|
|
414
|
+
if (separator === -1) continue;
|
|
415
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
416
|
+
|
|
417
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
418
|
+
// `encodeURIComponent` never escapes, so a decode buys nothing — and
|
|
419
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
420
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
421
|
+
// callback into a 500 with a stack trace.
|
|
422
|
+
values.push(part.slice(separator + 1).trim());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return values;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
clearBindingCookie(req: RouteRequest): void {
|
|
429
|
+
const { res } = req;
|
|
430
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
431
|
+
|
|
432
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
|
|
433
|
+
}
|
|
434
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared constants for the OAuth state/client-binding mechanism (#36).
|
|
2
|
+
//
|
|
3
|
+
// The binding cookie attributes are load-bearing, not cosmetic:
|
|
4
|
+
// - `SameSite=Lax` — the OAuth callback is a cross-site, top-level GET
|
|
5
|
+
// navigation initiated by the provider. `Strict` withholds the cookie on
|
|
6
|
+
// exactly that request and breaks login; `None` requires `Secure` and
|
|
7
|
+
// widens exposure for no benefit. `Lax` is the only correct value.
|
|
8
|
+
// - `Path=/auth` — the cookie is only ever read by the callback route.
|
|
9
|
+
// - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
10
|
+
|
|
11
|
+
export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
|
|
12
|
+
export const STATE_COOKIE_PATH = '/auth';
|
|
13
|
+
export const STATE_COOKIE_SAME_SITE = 'lax';
|
|
14
|
+
|
|
15
|
+
/** Lifetime of a pending state record, and the binding cookie's Max-Age. */
|
|
16
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
19
|
+
export const BINDING_VALUE_BYTES = 32;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
|
|
23
|
+
* a callback will try.
|
|
24
|
+
*
|
|
25
|
+
* A client can hold more than one cookie of the same name — a sibling subdomain
|
|
26
|
+
* can set one on the parent domain — and the browser sends every applicable
|
|
27
|
+
* cookie in one header, so all of them must be tried or a planted cookie denies
|
|
28
|
+
* login by sorting ahead of the real one (RFC 6265 section 5.4).
|
|
29
|
+
*
|
|
30
|
+
* A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
|
|
31
|
+
* threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
|
|
32
|
+
* session and 8 failed permanently — the same outcome as the original defect,
|
|
33
|
+
* with the attacker's cost raised from one planted cookie to eight. That is
|
|
34
|
+
* reachable: RFC 6265 section 5.4 orders by path length then creation time, so
|
|
35
|
+
* a 4-label API host with a foothold beneath it gets 3 settable parent domains
|
|
36
|
+
* x 3 usable paths = 9 candidates ahead of the real one.
|
|
37
|
+
*
|
|
38
|
+
* What the cap was defending is already bounded, structurally and for free.
|
|
39
|
+
* Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
|
|
40
|
+
* and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
|
|
41
|
+
* 20 bytes, so a request cannot present more than 779 hashable candidates.
|
|
42
|
+
* Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
|
|
43
|
+
* runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
|
|
44
|
+
* denial of login to avoid a third of a millisecond is the wrong trade, so the
|
|
45
|
+
* bound is left where it already was: the header size limit.
|
|
46
|
+
*/
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import config from 'stonyx/config';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
import { waitForModule } from 'stonyx';
|
|
4
|
+
import { setup, emit } from '@stonyx/events';
|
|
5
|
+
import RestServer from '@stonyx/rest-server';
|
|
6
|
+
import TokenManager from './token-manager.js';
|
|
7
|
+
import SessionManager from './session-manager.js';
|
|
8
|
+
import AuthRequest from './auth-request.js';
|
|
9
|
+
import StateStore from './state-store.js';
|
|
10
|
+
import type OAuthFlow from './oauth-flow.js';
|
|
11
|
+
|
|
12
|
+
setup(['authenticate']);
|
|
13
|
+
|
|
14
|
+
interface ProviderEntry {
|
|
15
|
+
flow: OAuthFlow;
|
|
16
|
+
tokenManager: TokenManager;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ProviderConfig {
|
|
20
|
+
module?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthorizationRequest {
|
|
25
|
+
/** Provider authorization URL to redirect the client to. */
|
|
26
|
+
url: string;
|
|
27
|
+
/**
|
|
28
|
+
* Client-held half of the state binding (#36). The caller must hand this to
|
|
29
|
+
* the client that started the flow — the auth routes set it as an HttpOnly
|
|
30
|
+
* cookie — and present it back to `handleCallback`.
|
|
31
|
+
*/
|
|
32
|
+
bindingValue: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default class OAuth {
|
|
36
|
+
static instance: OAuth | null;
|
|
37
|
+
|
|
38
|
+
providers = new Map<string, ProviderEntry>();
|
|
39
|
+
stateStore = new StateStore();
|
|
40
|
+
sessionManager!: SessionManager;
|
|
41
|
+
frontendCallbackUrl?: string;
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
if (OAuth.instance) return OAuth.instance;
|
|
45
|
+
OAuth.instance = this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async init(): Promise<void> {
|
|
49
|
+
// Self-register so log.oauth works even when @stonyx/oauth is in the
|
|
50
|
+
// consumer's `dependencies` (stonyx loader only merges devDependencies).
|
|
51
|
+
const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
|
|
52
|
+
log.defineType(logMethod, logColor);
|
|
53
|
+
|
|
54
|
+
const oauthConfig = config.oauth;
|
|
55
|
+
const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
|
|
56
|
+
this.frontendCallbackUrl = frontendCallbackUrl;
|
|
57
|
+
|
|
58
|
+
for (const [name, providerConfig] of Object.entries(providers)) {
|
|
59
|
+
const modulePath = providerConfig.module
|
|
60
|
+
? `${config.rootPath}/${providerConfig.module}`
|
|
61
|
+
: `./providers/${name}.js`;
|
|
62
|
+
const { default: Provider } = await import(modulePath);
|
|
63
|
+
const flow: OAuthFlow = new Provider(providerConfig);
|
|
64
|
+
this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.sessionManager = new SessionManager(sessionDuration);
|
|
68
|
+
|
|
69
|
+
await waitForModule('rest-server');
|
|
70
|
+
RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
|
|
71
|
+
|
|
72
|
+
log.oauth?.('OAuth module initialized');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getProvider(name: string): ProviderEntry {
|
|
76
|
+
const provider = this.providers.get(name);
|
|
77
|
+
if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
|
|
78
|
+
return provider;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest {
|
|
82
|
+
const { flow } = this.getProvider(providerName);
|
|
83
|
+
const { stateToken, bindingValue } = this.stateStore.issue(providerName);
|
|
84
|
+
|
|
85
|
+
return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `bindingValues` is required, not optional (#36). An optional parameter lets
|
|
90
|
+
* an existing three-argument call site keep compiling and then fail at
|
|
91
|
+
* runtime on the first real login; a compile error is the loudest disclosure
|
|
92
|
+
* channel available for this break.
|
|
93
|
+
*
|
|
94
|
+
* It is an array, not a single value, because a client can hold more than one
|
|
95
|
+
* cookie of the binding cookie's name and every one of them has to be tried —
|
|
96
|
+
* see `StateStore.anyCandidateMatches`. A caller driving the flow itself
|
|
97
|
+
* passes `[bindingValue]`; the route handler passes through every value the
|
|
98
|
+
* client presented, which may be none.
|
|
99
|
+
*/
|
|
100
|
+
async handleCallback(
|
|
101
|
+
providerName: string,
|
|
102
|
+
code: string,
|
|
103
|
+
stateToken: string,
|
|
104
|
+
bindingValues: readonly string[],
|
|
105
|
+
) {
|
|
106
|
+
this.stateStore.consume(stateToken, providerName, bindingValues);
|
|
107
|
+
|
|
108
|
+
const { flow, tokenManager } = this.getProvider(providerName);
|
|
109
|
+
const tokens = await tokenManager.getTokens(code);
|
|
110
|
+
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
111
|
+
const user = flow.normalizeUser(rawUser);
|
|
112
|
+
await emit('authenticate', user);
|
|
113
|
+
return this.sessionManager.create(user, tokens);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getSession(sessionId: string) {
|
|
117
|
+
return this.sessionManager.validate(sessionId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
logout(sessionId: string): void {
|
|
121
|
+
this.sessionManager.destroy(sessionId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
export interface OAuthConfig {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
scopes?: string[];
|
|
6
|
+
authorizationUrl: string;
|
|
7
|
+
tokenUrl: string;
|
|
8
|
+
userInfoUrl: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TokenResult {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string | null;
|
|
14
|
+
expiresIn: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
export default class OAuthFlow {
|
|
2
|
-
|
|
18
|
+
clientId: string;
|
|
19
|
+
clientSecret: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
scopes: string[];
|
|
22
|
+
authorizationUrl: string;
|
|
23
|
+
tokenUrl: string;
|
|
24
|
+
userInfoUrl: string;
|
|
25
|
+
|
|
26
|
+
constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }: OAuthConfig) {
|
|
3
27
|
this.clientId = clientId;
|
|
4
28
|
this.clientSecret = clientSecret;
|
|
5
29
|
this.redirectUri = redirectUri;
|
|
@@ -9,7 +33,7 @@ export default class OAuthFlow {
|
|
|
9
33
|
this.userInfoUrl = userInfoUrl;
|
|
10
34
|
}
|
|
11
35
|
|
|
12
|
-
buildAuthorizationUrl(stateToken) {
|
|
36
|
+
buildAuthorizationUrl(stateToken: string): string {
|
|
13
37
|
const params = new URLSearchParams({
|
|
14
38
|
client_id: this.clientId,
|
|
15
39
|
redirect_uri: this.redirectUri,
|
|
@@ -21,7 +45,7 @@ export default class OAuthFlow {
|
|
|
21
45
|
return `${this.authorizationUrl}?${params.toString()}`;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
async exchangeCode(code) {
|
|
48
|
+
async exchangeCode(code: string): Promise<TokenResult> {
|
|
25
49
|
const response = await fetch(this.tokenUrl, {
|
|
26
50
|
method: 'POST',
|
|
27
51
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -45,7 +69,7 @@ export default class OAuthFlow {
|
|
|
45
69
|
};
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
async refreshAccessToken(refreshToken) {
|
|
72
|
+
async refreshAccessToken(refreshToken: string): Promise<TokenResult> {
|
|
49
73
|
const response = await fetch(this.tokenUrl, {
|
|
50
74
|
method: 'POST',
|
|
51
75
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -68,7 +92,7 @@ export default class OAuthFlow {
|
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
|
71
|
-
async fetchUserInfo(accessToken) {
|
|
95
|
+
async fetchUserInfo(accessToken: string): Promise<unknown> {
|
|
72
96
|
const response = await fetch(this.userInfoUrl, {
|
|
73
97
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
74
98
|
});
|
|
@@ -78,11 +102,11 @@ export default class OAuthFlow {
|
|
|
78
102
|
return response.json();
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
normalizeUser(rawUser) {
|
|
105
|
+
normalizeUser(rawUser: unknown): unknown {
|
|
82
106
|
return { raw: rawUser };
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
async revokeToken(_accessToken) {
|
|
109
|
+
async revokeToken(_accessToken: string): Promise<void> {
|
|
86
110
|
// Optional — providers override if supported
|
|
87
111
|
}
|
|
88
112
|
}
|