@stonyx/oauth 0.1.1-alpha.21 → 0.1.1-alpha.23

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/dist/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
1
2
  import config from 'stonyx/config';
2
3
  import log from 'stonyx/log';
3
4
  import { waitForModule } from 'stonyx';
@@ -6,12 +7,16 @@ import RestServer from '@stonyx/rest-server';
6
7
  import TokenManager from './token-manager.js';
7
8
  import SessionManager from './session-manager.js';
8
9
  import AuthRequest from './auth-request.js';
9
- import StateStore from './state-store.js';
10
10
  setup(['authenticate']);
11
+ /** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
12
+ export const STATE_TTL_MS = 10 * 60 * 1000;
13
+ /** Entropy of the client-held binding value, in bytes. */
14
+ export const BINDING_VALUE_BYTES = 32;
11
15
  export default class OAuth {
12
16
  static instance;
13
17
  providers = new Map();
14
- stateStore = new StateStore();
18
+ pendingStates = new Map();
19
+ stateTtl = STATE_TTL_MS;
15
20
  sessionManager;
16
21
  frontendCallbackUrl;
17
22
  constructor() {
@@ -46,25 +51,112 @@ export default class OAuth {
46
51
  throw new Error(`OAuth provider "${name}" is not configured`);
47
52
  return provider;
48
53
  }
54
+ /**
55
+ * SHA-256 of a binding value, hex encoded.
56
+ *
57
+ * The pending record stores the digest so that read access to the map does
58
+ * not hand over the value a callback must present.
59
+ */
60
+ static hash(value) {
61
+ return createHash('sha256').update(value).digest('hex');
62
+ }
63
+ /** Length-independent, content-constant-time comparison of two digests. */
64
+ static digestsMatch(a, b) {
65
+ if (a.length !== b.length)
66
+ return false;
67
+ let difference = 0;
68
+ for (let index = 0; index < a.length; index++) {
69
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
70
+ }
71
+ return difference === 0;
72
+ }
73
+ /**
74
+ * Whether *any* presented value is the binding value for this record.
75
+ *
76
+ * Every candidate is tried, and the callback is accepted if one matches.
77
+ * Stopping at the first value carrying the cookie's name instead makes a
78
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
79
+ * section 5.4 orders the `Cookie` header by path length then creation time,
80
+ * so an attacker with content control on a sibling subdomain sets a
81
+ * same-named cookie once and every subsequent callback for that victim reads
82
+ * theirs, fails the binding check, and burns the state on the way out. The
83
+ * victim cannot recover by retrying.
84
+ *
85
+ * Accepting any match gives an attacker nothing: they would have to present
86
+ * the victim's own binding value, which is the property being checked. And
87
+ * the candidate list is deliberately uncapped — a cap does not bound an
88
+ * attack, it *is* one, reinstating that denial above its own threshold
89
+ * because the planted cookies are the ones that sort first. The work is
90
+ * already bounded by Node's 16 KB header limit.
91
+ *
92
+ * The reduce does not short-circuit, so the work is a function of how many
93
+ * values were presented and not of which one matched.
94
+ */
95
+ static anyCandidateMatches(candidates, bindingHash) {
96
+ return candidates.reduce((matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched, false);
97
+ }
98
+ /**
99
+ * Starts a flow: an OAuth2 `state` for the provider, and a binding value for
100
+ * the client that asked for it.
101
+ *
102
+ * `state` on its own is replay-window limiting, not the CSRF binding it
103
+ * exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
104
+ * state issued to any visitor validated for any callback, so an attacker
105
+ * could harvest their own state and code, deliver them to a victim over a
106
+ * plain link, and log the victim into the attacker's account. The binding
107
+ * value is the thing the victim's browser carries and the attacker's does
108
+ * not (#36).
109
+ */
49
110
  getAuthorizationUrl(providerName) {
50
111
  const { flow } = this.getProvider(providerName);
51
- const { stateToken, bindingValue } = this.stateStore.issue(providerName);
52
- return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
112
+ const stateToken = randomUUID();
113
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
114
+ this.pendingStates.set(stateToken, {
115
+ bindingHash: OAuth.hash(bindingValue),
116
+ createdAt: Date.now(),
117
+ });
118
+ return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
53
119
  }
54
120
  /**
55
- * `bindingValues` is required, not optional (#36). An optional parameter lets
56
- * an existing three-argument call site keep compiling and then fail at
57
- * runtime on the first real login; a compile error is the loudest disclosure
58
- * channel available for this break.
121
+ * Withdraws a state that was issued but could not be handed to a client.
59
122
  *
60
- * It is an array, not a single value, because a client can hold more than one
61
- * cookie of the binding cookie's name and every one of them has to be tried —
62
- * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
63
- * passes `[bindingValue]`; the route handler passes through every value the
64
- * client presented, which may be none.
123
+ * Used by the login route when the binding cookie cannot be set: a state the
124
+ * client cannot be bound to is exactly the defect this mechanism exists to
125
+ * prevent, so it must not outlive the request that failed to bind it.
126
+ */
127
+ discardState(stateToken) {
128
+ this.pendingStates.delete(stateToken);
129
+ }
130
+ /**
131
+ * Validates and consumes a pending state, then completes the flow.
132
+ *
133
+ * `bindingValues` is every value the client presented under the binding
134
+ * cookie's name — see `anyCandidateMatches`.
65
135
  */
66
136
  async handleCallback(providerName, code, stateToken, bindingValues) {
67
- this.stateStore.consume(stateToken, providerName, bindingValues);
137
+ const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
138
+ if (!record)
139
+ throw new Error('Invalid or missing state token');
140
+ // Consumed on recognition, before the TTL and binding checks, so every
141
+ // state gets exactly one attempt whatever the outcome. Checking the
142
+ // binding first would leave the record in place on a mismatch and turn
143
+ // this endpoint into a repeatable, unauthenticated oracle against the
144
+ // binding value for the state's full lifetime.
145
+ this.pendingStates.delete(stateToken);
146
+ if (Date.now() - record.createdAt > this.stateTtl) {
147
+ throw new Error('State token has expired');
148
+ }
149
+ // No "absent means skip". An empty candidate list is a rejection, which is
150
+ // what makes an attacker-delivered link fail for a victim who never
151
+ // started the flow and therefore holds no binding cookie.
152
+ const candidates = bindingValues.filter(value => value.length > 0);
153
+ if (candidates.length === 0)
154
+ throw new Error('Missing state binding value');
155
+ if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
156
+ throw new Error('State token is not bound to this client');
157
+ }
158
+ // Everything below burns a live authorization code, so the binding is
159
+ // settled before `exchangeCode` is ever reached.
68
160
  const { flow, tokenManager } = this.getProvider(providerName);
69
161
  const tokens = await tokenManager.getTokens(code);
70
162
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
@@ -72,6 +164,10 @@ export default class OAuth {
72
164
  await emit('authenticate', user);
73
165
  return this.sessionManager.create(user, tokens);
74
166
  }
167
+ /** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
168
+ redirectUriFor(providerName) {
169
+ return this.providers.get(providerName)?.flow.redirectUri;
170
+ }
75
171
  getSession(sessionId) {
76
172
  return this.sessionManager.validate(sessionId);
77
173
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.21",
7
+ "version": "0.1.1-alpha.23",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -55,15 +55,15 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@stonyx/events": "0.1.1-beta.52",
58
- "stonyx": "0.2.3-beta.77"
58
+ "stonyx": "0.2.3-beta.81"
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.83",
64
+ "@stonyx/rest-server": "0.2.1-beta.98",
65
65
  "@stonyx/utils": "0.2.3-beta.26",
66
- "@stonyx/logs": "1.0.1-beta.19",
66
+ "@stonyx/logs": "1.0.1-beta.20",
67
67
  "@types/qunit": "^2.19.13",
68
68
  "@types/sinon": "^21.0.1",
69
69
  "qunit": "^2.24.1",
@@ -1,71 +1,39 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
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
3
 
16
4
  /**
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`.
5
+ * The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
20
6
  *
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 `.`.
7
+ * The attributes below are load-bearing, not cosmetic:
32
8
  *
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`.
9
+ * - `SameSite=Lax`the callback is a cross-site, top-level GET navigation
10
+ * initiated by the provider. `Strict` withholds the cookie on exactly that
11
+ * request, breaking 100% of logins while passing every CSRF test; `None`
12
+ * requires `Secure` and widens exposure for no benefit.
13
+ * - `Path=/` — routing is case-insensitive today
14
+ * (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
15
+ * RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
16
+ * `/auth` silently drops the cookie on a case-varied callback and breaks
17
+ * login.
18
+ * - `HttpOnly` script must not be able to read or forge the binding value.
51
19
  */
52
- function isLoopbackIpv6(hostname: string): boolean {
53
- const mapped = /^::ffff:(.+)$/.exec(hostname);
54
- if (!mapped) return false;
20
+ const STATE_COOKIE_NAME = 'oauth_state';
21
+ const STATE_COOKIE_PATH = '/';
22
+ const STATE_COOKIE_SAME_SITE = 'lax';
55
23
 
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;
24
+ interface AuthorizationRequest {
25
+ url: string;
26
+ stateToken: string;
27
+ bindingValue: string;
63
28
  }
64
29
 
65
30
  interface OAuthInstance {
66
31
  frontendCallbackUrl?: string;
32
+ stateTtl: number;
67
33
  getSession(sessionId: string): unknown;
68
34
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
35
+ discardState(stateToken: string): void;
36
+ redirectUriFor(providerName: string): string | undefined;
69
37
  handleCallback(
70
38
  providerName: string,
71
39
  code: string,
@@ -84,13 +52,17 @@ interface CookieOptions {
84
52
  }
85
53
 
86
54
  /**
87
- * The response object Express hangs off the request.
55
+ * The response object express hangs off the request.
88
56
  *
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.
57
+ * `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
58
+ * is unreachable once `state.redirect` is set (`request.ts` returns on the
59
+ * redirect first), so setting a cookie means reaching for `req.res`.
60
+ *
61
+ * This is a deliberate, sanctioned interim reach-around, not an accident:
62
+ * `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
63
+ * first-class header/cookie affordance to migrate onto, and it is sequenced
64
+ * after this fix. `setBindingCookie` fails closed if the affordance is not
65
+ * there, which is what contains the dependency.
94
66
  */
95
67
  interface ResponseLike {
96
68
  cookie(name: string, value: string, options: CookieOptions): unknown;
@@ -99,16 +71,8 @@ interface ResponseLike {
99
71
 
100
72
  interface RouteRequest {
101
73
  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
74
  params: Record<string, string>;
110
75
  query: Record<string, string>;
111
- secure?: boolean;
112
76
  res?: ResponseLike;
113
77
  }
114
78
 
@@ -146,9 +110,13 @@ export default class AuthRequest extends Request {
146
110
  return 404;
147
111
  }
148
112
 
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;
113
+ // Fail closed. A state we cannot bind to this client is exactly the
114
+ // defect this mechanism exists to prevent, so it is withdrawn rather
115
+ // than issued unbindable.
116
+ if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
117
+ this.oauth.discardState(authorization.stateToken);
118
+ return 500;
119
+ }
152
120
 
153
121
  state.redirect = authorization.url;
154
122
  },
@@ -157,8 +125,6 @@ export default class AuthRequest extends Request {
157
125
  const { provider: providerName } = req.params;
158
126
  const { code, state: stateToken, error } = req.query;
159
127
 
160
- const bindingValues = this.readBindingCookies(req);
161
-
162
128
  if (error) {
163
129
  if (this.oauth.frontendCallbackUrl) {
164
130
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -170,11 +136,23 @@ export default class AuthRequest extends Request {
170
136
  if (!code) return 400;
171
137
 
172
138
  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);
139
+ const session = await this.oauth.handleCallback(
140
+ providerName,
141
+ code,
142
+ stateToken,
143
+ this.readBindingCookies(req),
144
+ );
145
+
146
+ // Cleared only here, on the success path, which is the only path that
147
+ // is certain to have consumed a state belonging to *this* client.
148
+ //
149
+ // Clearing on failure instead looks harmless and is not: `code` is
150
+ // attacker-supplied and unvalidated, so a bare `?code=1` — no
151
+ // knowledge of anyone's state — would delete the binding cookie of a
152
+ // client still sitting on the provider's consent screen, leaving
153
+ // their pending state untouched so nothing is detectable
154
+ // server-side, and their real callback then fails.
155
+ this.clearBindingCookie(req, providerName);
178
156
 
179
157
  if (this.oauth.frontendCallbackUrl) {
180
158
  const params = new URLSearchParams({
@@ -186,53 +164,7 @@ export default class AuthRequest extends Request {
186
164
  }
187
165
 
188
166
  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
-
167
+ } catch {
236
168
  if (this.oauth.frontendCallbackUrl) {
237
169
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
238
170
  return;
@@ -248,128 +180,44 @@ export default class AuthRequest extends Request {
248
180
  }
249
181
  };
250
182
 
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
183
  /**
264
184
  * Whether the binding cookie is issued with `Secure`.
265
185
  *
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.
186
+ * Derived from the scheme of the provider's configured `redirectUri`, which
187
+ * is the deployment's own statement of the origin this cookie has to survive
188
+ * a round trip to.
272
189
  *
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]`.
190
+ * Not `req.secure`: express derives that from the socket unless `trust proxy`
191
+ * is on, and `@stonyx/rest-server` leaves it off by default, so in the
192
+ * standard production topology TLS terminated at a proxy, plaintext to the
193
+ * origin — `req.secure` is `false` on every request to an HTTPS site and the
194
+ * cookie would ship without `Secure` while the deployment looks correct. Not
195
+ * the `Host` header either: that is attacker-controllable on any non-browser
196
+ * client. And not hardcoded `true`, which breaks plaintext local development.
323
197
  *
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.
198
+ * An unparseable or absent redirect URI fails secure.
328
199
  */
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();
200
+ isSecureContext(providerName: string): boolean {
201
+ const redirectUri = this.oauth.redirectUriFor(providerName);
202
+ if (!redirectUri) return true;
203
+
204
+ try {
205
+ return new URL(redirectUri).protocol !== 'http:';
206
+ } catch {
207
+ return true;
341
208
  }
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
209
  }
352
210
 
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);
211
+ cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
212
+ return {
213
+ httpOnly: true,
214
+ sameSite: STATE_COOKIE_SAME_SITE,
215
+ path: STATE_COOKIE_PATH,
216
+ secure: this.isSecureContext(providerName),
217
+ };
370
218
  }
371
219
 
372
- setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
220
+ setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
373
221
  const { res } = req;
374
222
 
375
223
  if (typeof res?.cookie !== 'function') {
@@ -378,8 +226,8 @@ export default class AuthRequest extends Request {
378
226
  }
379
227
 
380
228
  res.cookie(STATE_COOKIE_NAME, bindingValue, {
381
- ...this.cookieOptions(req),
382
- maxAge: STATE_TTL_MS,
229
+ ...this.cookieOptions(providerName),
230
+ maxAge: this.oauth.stateTtl,
383
231
  });
384
232
 
385
233
  return true;
@@ -388,20 +236,9 @@ export default class AuthRequest extends Request {
388
236
  /**
389
237
  * Every value the client presented under the binding cookie's name.
390
238
  *
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.
239
+ * Not the first one, and not capped see `OAuth.anyCandidateMatches` for why
240
+ * either would hand an attacker a permanent, unauthenticated denial of login
241
+ * for any victim they can plant a same-named cookie on.
405
242
  */
406
243
  readBindingCookies(req: RouteRequest): string[] {
407
244
  const header = req.headers.cookie;
@@ -415,20 +252,20 @@ export default class AuthRequest extends Request {
415
252
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
416
253
 
417
254
  // Not decoded. The binding value is base64url, whose alphabet
418
- // `encodeURIComponent` never escapes, so a decode buys nothing — and
255
+ // `encodeURIComponent` never escapes, so decoding buys nothing — and
419
256
  // `decodeURIComponent` throws `URIError` on malformed input, which any
420
257
  // unauthenticated caller can supply, turning the first line of the
421
- // callback into a 500 with a stack trace.
258
+ // callback into a 500.
422
259
  values.push(part.slice(separator + 1).trim());
423
260
  }
424
261
 
425
262
  return values;
426
263
  }
427
264
 
428
- clearBindingCookie(req: RouteRequest): void {
265
+ clearBindingCookie(req: RouteRequest, providerName: string): void {
429
266
  const { res } = req;
430
267
  if (typeof res?.clearCookie !== 'function') return;
431
268
 
432
- res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
269
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
433
270
  }
434
271
  }