@stonyx/oauth 0.1.1-alpha.16 → 0.1.1-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ OAuth2 authentication module for the Stonyx framework. Provides a generic OAuth2
11
11
  Add as a devDependency to your Stonyx project:
12
12
 
13
13
  ```bash
14
- npm install @stonyx/oauth
14
+ pnpm add @stonyx/oauth
15
15
  ```
16
16
 
17
17
  Requires `@stonyx/rest-server` as a peer dependency.
@@ -86,25 +86,111 @@ issue time, and sends the plaintext to the client as a cookie:
86
86
  | `HttpOnly` | set | script must not be able to read or forge the binding value |
87
87
  | `SameSite` | `Lax` | **required.** The callback is a cross-site, top-level `GET` navigation from the provider. `SameSite=Strict` withholds the cookie on exactly that request and breaks login outright; `SameSite=None` requires `Secure` and widens exposure for no benefit |
88
88
  | `Path` | `/auth` | the cookie is only ever read by the callback route |
89
- | `Secure` | set when the request arrived over HTTPS | |
89
+ | `Secure` | set on every host except loopback | deriving it from `req.secure` would omit it in the standard production topology: behind a TLS-terminating proxy Express reports the request as plaintext unless `trust proxy` is enabled, and `@stonyx/rest-server` leaves that off by default. A non-loopback plaintext deployment therefore cannot store this cookie — that failure is loud and deliberate, in preference to a silently insecure production cookie. The exemption rule is exact; see below |
90
90
  | `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
91
91
 
92
+ #### Which hosts are exempt from `Secure`
93
+
94
+ The exemption is decided by parsing the `Host` header and testing the result for
95
+ membership. It is never a prefix or suffix match on the raw header value,
96
+ because `Host` is attacker-controllable from any non-browser client.
97
+
98
+ Exempt, and nothing else:
99
+
100
+ - `localhost` (case-insensitive)
101
+ - any address in `127.0.0.0/8` — a dotted quad of four decimal octets whose
102
+ first is `127`, so `127.0.0.2` is exempt and `127.evil.com` is not
103
+ - `::1` (and its expanded form), and the IPv4-mapped loopback spellings
104
+ `::ffff:127.0.0.1` and `::ffff:7f00:1`
105
+ - the wildcard bind addresses `0.0.0.0` and `::`
106
+
107
+ Everything else gets `Secure`, including:
108
+
109
+ - **`*.localhost`.** A `.localhost` suffix was exempt in an earlier draft of
110
+ this module and is not any more: a split-horizon vhost under that zone would
111
+ have shipped the binding value in cleartext. Develop against `localhost` or
112
+ `127.0.0.1`, which reach the same server.
113
+ - any `Host` that is not a well-formed `host[:port]` — a non-numeric port, a
114
+ userinfo segment (`localhost:80@evil.com`), a comma-joined multi-value, or a
115
+ character a registered name may not contain
116
+ - a request carrying **more than one `Host` header**. Node collapses repeats
117
+ into the first value, so an upstream component that prepends rather than
118
+ replaces a `Host:` line could otherwise downgrade the cookie on a response to
119
+ someone else. RFC 9112 section 3.2 makes such a request invalid; this module
120
+ treats it as unattributable.
121
+ - a request carrying **no `Host` header** at all
122
+
123
+ `X-Forwarded-Host` is deliberately **not** consulted.
124
+
92
125
  `GET /auth/callback/:provider` accepts the callback only when all of the
93
126
  following hold, and mints no session otherwise:
94
127
 
95
128
  - the `state` is one this server issued and has not already been used
96
129
  - it was issued for **this** provider
97
130
  - it was issued less than 10 minutes ago
98
- - the request carries the binding cookie whose value hashes to the stored digest
131
+ - the request carries a binding cookie whose value hashes to the stored digest
132
+
133
+ A client can hold more than one cookie of that name — a sibling subdomain can
134
+ set one on the parent domain, and the browser sends every applicable cookie in
135
+ a single header. **Every** value carrying the name is tried, up to eight, and
136
+ the callback is accepted if any of them matches. Reading only the first would
137
+ let anyone who can plant a cookie on the victim's domain deny them login
138
+ permanently: RFC 6265 section 5.4 sorts a planted cookie ahead of the real one
139
+ on equal paths, and the state is burned on every recognised callback, so
140
+ retrying does not help. Trying all of them concedes nothing, because the
141
+ attacker would still have to present the victim's own binding value.
99
142
 
100
143
  The state and the cookie are both single-use: the pending record is consumed on
101
- any callback that presents a recognised `state` — successful or not — and the
102
- callback response clears the cookie.
103
-
104
- If the cookie cannot be set, `GET /auth/login/:provider` responds `500` rather
105
- than issuing a state it cannot bind. A reverse proxy or CDN that strips
106
- `Set-Cookie` from redirect responses will therefore break login rather than
107
- silently degrade it.
144
+ any callback that presents a recognised `state` — successful or not — and that
145
+ same response clears the cookie, scoped to `Path=/auth` so the deletion actually
146
+ reaches the cookie that was set.
147
+
148
+ A callback that consumes **nothing** an unrecognised or absent `state`, a
149
+ provider `error`, a missing `code` leaves the cookie alone. That is not
150
+ tidiness: `/auth/callback/:provider?code=1` is a request any attacker can induce
151
+ as a top-level navigation while a victim is still at the provider's consent
152
+ screen, and clearing on it would delete the victim's binding cookie without
153
+ touching anything the server could later notice.
154
+
155
+ Two distinct failure modes surface on two different routes. They are unrelated,
156
+ and the route is the fastest way to tell them apart:
157
+
158
+ - **The cookie cannot be set at all.** `GET /auth/login/:provider` responds
159
+ `500` rather than issuing a state it cannot bind, and logs
160
+ `OAuth: unable to set the state binding cookie; login rejected`. This is a
161
+ framework-wiring condition — the response object the module reaches for is
162
+ not there — not a network or proxy one.
163
+ - **`Set-Cookie` is stripped in transit** by a reverse proxy or CDN.
164
+ `GET /auth/login/:provider` **succeeds and redirects normally**; the module
165
+ never learns the header was dropped. The failure surfaces one hop later, at
166
+ `GET /auth/callback/:provider`, as `?error=auth_failed` on
167
+ `frontendCallbackUrl` (or a bare `500` when it is unset), with
168
+ `OAuth: callback rejected — Missing state binding value` in the log. First
169
+ thing to check: does the login response reach the browser carrying
170
+ `Set-Cookie: stonyx_oauth_state`.
171
+
172
+ Every callback rejection is logged server-side with its reason
173
+ (`OAuth: callback rejected — ...`), which distinguishes an unknown state, a
174
+ wrong provider, an expired state, a missing binding value and a wrong binding
175
+ value. Those five strings are this module's own; nothing caller-controlled
176
+ appears in them. The client-facing `auth_failed` stays deliberately opaque.
177
+
178
+ A failure thrown **below** the state check — by `getProvider`, by a provider's
179
+ `exchangeCode`, `fetchUserInfo` or `normalizeUser`, by an `authenticate`
180
+ subscriber, or by session creation — logs the fixed line
181
+ `OAuth: callback failed after state validation` and **nothing from the error
182
+ itself**. Custom providers are consumer code, and a provider error that carries
183
+ request context would otherwise put a `clientSecret` or the caller-supplied
184
+ `code` into your logs verbatim. If you need that detail, log it inside your
185
+ provider, where you control what goes in it.
186
+
187
+ A failed callback **cannot be retried**: the pending record is consumed on any
188
+ callback presenting a recognised `state`, so refreshing the error page or going
189
+ back and forward produces a second `auth_failed`. The user must restart at
190
+ `GET /auth/login/:provider`. Only one login can be in flight per browser at a
191
+ time, for the same reason — the binding cookie has one fixed name, so starting
192
+ a second login overwrites the first flow's binding value and the earlier flow
193
+ will fail at its callback.
108
194
 
109
195
  ### Custom flow drivers
110
196
 
@@ -114,14 +200,18 @@ carry the binding value between the two calls:
114
200
  ```javascript
115
201
  const { url, bindingValue } = oauth.getAuthorizationUrl('discord');
116
202
  // hand bindingValue to the client, then on the callback:
117
- const session = await oauth.handleCallback('discord', code, state, bindingValue);
203
+ const session = await oauth.handleCallback('discord', code, state, [bindingValue]);
118
204
  ```
119
205
 
206
+ The fourth argument is an **array** — every value the client presented under the
207
+ binding cookie's name. A driver that holds exactly one value passes
208
+ `[bindingValue]`; one that holds none passes `[]`.
209
+
120
210
  > **Changed in the release that fixes [#36](https://github.com/abofs/stonyx-oauth/issues/36):**
121
211
  > `getAuthorizationUrl(provider)` returned a URL string and now returns
122
212
  > `{ url, bindingValue }`; `handleCallback(provider, code, state)` takes a
123
- > fourth argument, the client's binding value. Applications using the
124
- > self-registering `/auth` routes need no changes.
213
+ > required fourth argument, the client's binding values, as an array.
214
+ > Applications using the self-registering `/auth` routes need no changes.
125
215
 
126
216
  ## Officially Supported Providers
127
217
 
@@ -184,7 +274,11 @@ providers: {
184
274
 
185
275
  Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
186
276
  Pending OAuth states are held in-memory too, so a restart mid-login, or more
187
- than one instance behind a load balancer, will reject the callback.
277
+ than one instance behind a load balancer, will reject the callback. Pending
278
+ records are removed when a callback consumes them, not swept on a timer — the
279
+ ten-minute age bound is only evaluated when a matching callback arrives, so an
280
+ abandoned flow's record persists until the process restarts. See
281
+ [#38](https://github.com/abofs/stonyx-oauth/issues/38).
188
282
 
189
283
  Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
190
284
 
@@ -7,7 +7,7 @@ interface OAuthInstance {
7
7
  frontendCallbackUrl?: string;
8
8
  getSession(sessionId: string): unknown;
9
9
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
10
- handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string): Promise<{
10
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<{
11
11
  sessionId: string;
12
12
  expiresAt: number;
13
13
  }>;
@@ -35,6 +35,13 @@ interface ResponseLike {
35
35
  }
36
36
  interface RouteRequest {
37
37
  headers: Record<string, string | undefined>;
38
+ /**
39
+ * Node's flat `[name, value, name, value, ...]` header list, when the runtime
40
+ * supplies it. Read only to detect a *duplicate* `Host`: Node collapses
41
+ * repeats into the first value, so `req.headers.host` alone cannot tell an
42
+ * unambiguous origin from a smuggled one.
43
+ */
44
+ rawHeaders?: string[];
38
45
  params: Record<string, string>;
39
46
  query: Record<string, string>;
40
47
  secure?: boolean;
@@ -58,8 +65,79 @@ export default class AuthRequest extends Request {
58
65
  };
59
66
  };
60
67
  cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
68
+ /**
69
+ * Whether the binding cookie is issued with `Secure`.
70
+ *
71
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
72
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
73
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
74
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
75
+ * is therefore `false` on every request to an HTTPS site, and the binding
76
+ * cookie would ship without `Secure` while the deployment looks correct.
77
+ *
78
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
79
+ * wrong there breaks a non-loopback plaintext development setup, which fails
80
+ * at the first login and is loud. The alternative fails silently, in
81
+ * production, on the one attribute protecting the value this whole mechanism
82
+ * is built around.
83
+ *
84
+ * The exemption is decided by *parsing* the `Host` header and testing the
85
+ * result for membership, never by matching a prefix or a suffix on the raw
86
+ * value — `Host` is attacker-controllable on any non-browser client, and a
87
+ * security predicate written as a substring match drifts. Every shape that
88
+ * cannot be parsed as a bare `host[:port]`, and every request with more than
89
+ * one `Host`, fails secure.
90
+ */
91
+ isSecureContext(req: RouteRequest): boolean;
92
+ /**
93
+ * True when the request carried more than one `Host` header.
94
+ *
95
+ * Node keeps the first and discards the rest, so a component that *prepends*
96
+ * a `Host:` line — request smuggling, or a proxy that appends rather than
97
+ * replaces — can make `req.headers.host` read `localhost` on a request whose
98
+ * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
99
+ * this treats it as unattributable and fails secure rather than trusting it.
100
+ */
101
+ static hasAmbiguousHost(req: RouteRequest): boolean;
102
+ /**
103
+ * The hostname component of a `Host` header, lowercased, or `undefined` when
104
+ * the value is not a well-formed `host[:port]`.
105
+ *
106
+ * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
107
+ * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
108
+ * required to be decimal, and the hostname to contain only characters a
109
+ * registered name may contain.
110
+ */
111
+ static parseHostname(host: string): string | undefined;
112
+ /**
113
+ * Whether a parsed hostname is a loopback development origin.
114
+ *
115
+ * Membership tests, never prefix or suffix tests. `startsWith('127.')`
116
+ * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
117
+ * leading digit in a label), and `endsWith('.localhost')` exempted an entire
118
+ * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
119
+ * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
120
+ * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
121
+ * never documented it, and a developer on `app.localhost` reaches the same
122
+ * server on `localhost` or `127.0.0.1`.
123
+ */
124
+ static isLoopbackHost(hostname: string): boolean;
61
125
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
62
- readBindingCookie(req: RouteRequest): string | undefined;
126
+ /**
127
+ * Every value the client presented under the binding cookie's name.
128
+ *
129
+ * Not the first one. A browser sends every applicable cookie in a single
130
+ * header, and a sibling subdomain can set a same-named cookie on the parent
131
+ * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
132
+ * returning on the first name match handed an attacker a permanent,
133
+ * unauthenticated denial of login for any victim they could plant a cookie
134
+ * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
135
+ * is writing, not reading.
136
+ *
137
+ * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
138
+ * caller can ask for is capped whatever the header contains.
139
+ */
140
+ readBindingCookies(req: RouteRequest): string[];
63
141
  clearBindingCookie(req: RouteRequest): void;
64
142
  }
65
143
  export {};
@@ -1,6 +1,52 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
- import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
3
+ import { StateRejection } from './state-store.js';
4
+ import { MAX_BINDING_COOKIE_CANDIDATES, STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
5
+ /**
6
+ * Hosts treated as a development origin by exact match, and — together with
7
+ * `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
8
+ * from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
9
+ *
10
+ * `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
11
+ * local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
12
+ * listed here, so the two are not silently redundant.
13
+ */
14
+ const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
15
+ /** `host` values whose port component is anything but a decimal port are rejected. */
16
+ const PORT_PATTERN = /^\d{1,5}$/;
17
+ /**
18
+ * The characters RFC 1123 permits in a registered hostname, plus `.`.
19
+ *
20
+ * Anything else — `@`, `,`, whitespace, `/` — means the value is not a bare
21
+ * hostname, and the caller fails secure rather than guessing. This is what
22
+ * rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
23
+ */
24
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
25
+ /** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
26
+ function isLoopbackIpv4(hostname) {
27
+ const octets = hostname.split('.');
28
+ if (octets.length !== 4)
29
+ return false;
30
+ if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255))
31
+ return false;
32
+ return Number(octets[0]) === 127;
33
+ }
34
+ /**
35
+ * IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
36
+ * `::ffff:127.0.0.1` and `::ffff:7f00:1`.
37
+ */
38
+ function isLoopbackIpv6(hostname) {
39
+ const mapped = /^::ffff:(.+)$/.exec(hostname);
40
+ if (!mapped)
41
+ return false;
42
+ const rest = mapped[1];
43
+ if (isLoopbackIpv4(rest))
44
+ return true;
45
+ const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
46
+ if (!hextets)
47
+ return false;
48
+ return parseInt(hextets[1], 16) >>> 8 === 127;
49
+ }
4
50
  export default class AuthRequest extends Request {
5
51
  oauth;
6
52
  constructor(oauth) {
@@ -36,10 +82,7 @@ export default class AuthRequest extends Request {
36
82
  '/callback/:provider': async (req, state) => {
37
83
  const { provider: providerName } = req.params;
38
84
  const { code, state: stateToken, error } = req.query;
39
- // The binding value is single-use: whatever the outcome below, this
40
- // callback is the end of that cookie's life.
41
- const bindingValue = this.readBindingCookie(req);
42
- this.clearBindingCookie(req);
85
+ const bindingValues = this.readBindingCookies(req);
43
86
  if (error) {
44
87
  if (this.oauth.frontendCallbackUrl) {
45
88
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -50,7 +93,10 @@ export default class AuthRequest extends Request {
50
93
  if (!code)
51
94
  return 400;
52
95
  try {
53
- const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
96
+ const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValues);
97
+ // The binding value is single-use and the state has now been
98
+ // consumed, so this is the end of that cookie's life.
99
+ this.clearBindingCookie(req);
54
100
  if (this.oauth.frontendCallbackUrl) {
55
101
  const params = new URLSearchParams({
56
102
  sessionId: session.sessionId,
@@ -61,7 +107,52 @@ export default class AuthRequest extends Request {
61
107
  }
62
108
  return session;
63
109
  }
64
- catch {
110
+ catch (rejection) {
111
+ // Clear only when this request actually spent the cookie.
112
+ //
113
+ // Moving the clear below the `error` and `!code` returns was not
114
+ // enough: it still ran unconditionally for any request carrying a
115
+ // `code`, and `code` is attacker-supplied and unvalidated. So
116
+ // `?code=1` — one query parameter, no knowledge of the victim's state
117
+ // — deleted the binding cookie of a client still at the provider's
118
+ // consent screen, leaving their pending state untouched so nothing
119
+ // was detectable server-side, and their real callback then failed.
120
+ //
121
+ // `StateRejection.consumed` is the only thing that distinguishes
122
+ // "nothing of this client's was touched" from "one attempt was
123
+ // spent". Anything that is not a `StateRejection` was thrown below
124
+ // the state check, which means the record was already burned.
125
+ if (!(rejection instanceof StateRejection) || rejection.consumed) {
126
+ this.clearBindingCookie(req);
127
+ }
128
+ // `StateStore.consume` distinguishes five rejection reasons that
129
+ // otherwise collapse into one opaque outcome with no server-side
130
+ // signal at all. The client-facing `auth_failed` stays opaque; the
131
+ // server has no reason to be.
132
+ //
133
+ // Only a `StateRejection`'s message is logged, and those are the
134
+ // fixed strings in `STATE_REJECTION`. The `try` above spans far more
135
+ // than `consume` — `getProvider`, `TokenManager.getTokens` ->
136
+ // `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
137
+ // `emit('authenticate')`, `sessionManager.create` — and three of
138
+ // those are consumer-overridable through the documented
139
+ // `providers.<name>.module` extension point. A provider that puts
140
+ // request context in its error, which is ordinary practice, would
141
+ // otherwise land its `clientSecret` and the caller-supplied `code` in
142
+ // the log verbatim; `@stonyx/logs` appends content raw when
143
+ // `logToFile` is enabled, so an echoed `code` is also a CRLF
144
+ // log-forging primitive for an unauthenticated caller. Before this
145
+ // module logged anything, all of that was swallowed.
146
+ //
147
+ // Anything below the state check therefore gets a fixed
148
+ // discriminator, and the detail is left to whatever the provider
149
+ // itself logs.
150
+ if (rejection instanceof StateRejection) {
151
+ log.error(`OAuth: callback rejected — ${rejection.message}`);
152
+ }
153
+ else {
154
+ log.error('OAuth: callback failed after state validation');
155
+ }
65
156
  if (this.oauth.frontendCallbackUrl) {
66
157
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
67
158
  return;
@@ -84,9 +175,114 @@ export default class AuthRequest extends Request {
84
175
  // request and breaks login outright.
85
176
  sameSite: STATE_COOKIE_SAME_SITE,
86
177
  path: STATE_COOKIE_PATH,
87
- secure: req.secure === true,
178
+ secure: this.isSecureContext(req),
88
179
  };
89
180
  }
181
+ /**
182
+ * Whether the binding cookie is issued with `Secure`.
183
+ *
184
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
185
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
186
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
187
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
188
+ * is therefore `false` on every request to an HTTPS site, and the binding
189
+ * cookie would ship without `Secure` while the deployment looks correct.
190
+ *
191
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
192
+ * wrong there breaks a non-loopback plaintext development setup, which fails
193
+ * at the first login and is loud. The alternative fails silently, in
194
+ * production, on the one attribute protecting the value this whole mechanism
195
+ * is built around.
196
+ *
197
+ * The exemption is decided by *parsing* the `Host` header and testing the
198
+ * result for membership, never by matching a prefix or a suffix on the raw
199
+ * value — `Host` is attacker-controllable on any non-browser client, and a
200
+ * security predicate written as a substring match drifts. Every shape that
201
+ * cannot be parsed as a bare `host[:port]`, and every request with more than
202
+ * one `Host`, fails secure.
203
+ */
204
+ isSecureContext(req) {
205
+ if (req.secure === true)
206
+ return true;
207
+ if (AuthRequest.hasAmbiguousHost(req))
208
+ return true;
209
+ const host = req.headers.host;
210
+ if (!host)
211
+ return true;
212
+ const hostname = AuthRequest.parseHostname(host);
213
+ if (hostname === undefined)
214
+ return true;
215
+ return !AuthRequest.isLoopbackHost(hostname);
216
+ }
217
+ /**
218
+ * True when the request carried more than one `Host` header.
219
+ *
220
+ * Node keeps the first and discards the rest, so a component that *prepends*
221
+ * a `Host:` line — request smuggling, or a proxy that appends rather than
222
+ * replaces — can make `req.headers.host` read `localhost` on a request whose
223
+ * real origin is public. RFC 9112 section 3.2 makes such a request invalid;
224
+ * this treats it as unattributable and fails secure rather than trusting it.
225
+ */
226
+ static hasAmbiguousHost(req) {
227
+ const raw = req.rawHeaders;
228
+ if (!Array.isArray(raw))
229
+ return false;
230
+ let seen = 0;
231
+ for (let index = 0; index < raw.length; index += 2) {
232
+ if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host')
233
+ seen++;
234
+ }
235
+ return seen > 1;
236
+ }
237
+ /**
238
+ * The hostname component of a `Host` header, lowercased, or `undefined` when
239
+ * the value is not a well-formed `host[:port]`.
240
+ *
241
+ * `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
242
+ * `localhost:80@evil.com` reduces to `localhost`. The port is therefore
243
+ * required to be decimal, and the hostname to contain only characters a
244
+ * registered name may contain.
245
+ */
246
+ static parseHostname(host) {
247
+ if (host.startsWith('[')) {
248
+ const close = host.indexOf(']');
249
+ if (close === -1)
250
+ return undefined;
251
+ const port = host.slice(close + 1);
252
+ if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1))))
253
+ return undefined;
254
+ const literal = host.slice(1, close);
255
+ if (!/^[0-9A-Fa-f:.]+$/.test(literal))
256
+ return undefined;
257
+ return literal.toLowerCase();
258
+ }
259
+ const colon = host.indexOf(':');
260
+ if (colon === -1)
261
+ return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
262
+ if (!PORT_PATTERN.test(host.slice(colon + 1)))
263
+ return undefined;
264
+ const name = host.slice(0, colon);
265
+ return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
266
+ }
267
+ /**
268
+ * Whether a parsed hostname is a loopback development origin.
269
+ *
270
+ * Membership tests, never prefix or suffix tests. `startsWith('127.')`
271
+ * matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
272
+ * leading digit in a label), and `endsWith('.localhost')` exempted an entire
273
+ * suffix — so a `.localhost` split-horizon vhost shipped the binding value in
274
+ * cleartext. The `.localhost` exemption is withdrawn rather than tightened:
275
+ * the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
276
+ * never documented it, and a developer on `app.localhost` reaches the same
277
+ * server on `localhost` or `127.0.0.1`.
278
+ */
279
+ static isLoopbackHost(hostname) {
280
+ if (LOOPBACK_HOSTS.has(hostname))
281
+ return true;
282
+ if (isLoopbackIpv4(hostname))
283
+ return true;
284
+ return isLoopbackIpv6(hostname);
285
+ }
90
286
  setBindingCookie(req, bindingValue) {
91
287
  const { res } = req;
92
288
  if (typeof res?.cookie !== 'function') {
@@ -99,19 +295,41 @@ export default class AuthRequest extends Request {
99
295
  });
100
296
  return true;
101
297
  }
102
- readBindingCookie(req) {
298
+ /**
299
+ * Every value the client presented under the binding cookie's name.
300
+ *
301
+ * Not the first one. A browser sends every applicable cookie in a single
302
+ * header, and a sibling subdomain can set a same-named cookie on the parent
303
+ * domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
304
+ * returning on the first name match handed an attacker a permanent,
305
+ * unauthenticated denial of login for any victim they could plant a cookie
306
+ * on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
307
+ * is writing, not reading.
308
+ *
309
+ * Bounded at `MAX_BINDING_COOKIE_CANDIDATES`, so the work an unauthenticated
310
+ * caller can ask for is capped whatever the header contains.
311
+ */
312
+ readBindingCookies(req) {
103
313
  const header = req.headers.cookie;
104
314
  if (!header)
105
- return undefined;
315
+ return [];
316
+ const values = [];
106
317
  for (const part of header.split(';')) {
318
+ if (values.length >= MAX_BINDING_COOKIE_CANDIDATES)
319
+ break;
107
320
  const separator = part.indexOf('=');
108
321
  if (separator === -1)
109
322
  continue;
110
323
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
111
324
  continue;
112
- return decodeURIComponent(part.slice(separator + 1).trim());
325
+ // Not decoded. The binding value is base64url, whose alphabet
326
+ // `encodeURIComponent` never escapes, so a decode buys nothing — and
327
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
328
+ // unauthenticated caller can supply, turning the first line of the
329
+ // callback into a 500 with a stack trace.
330
+ values.push(part.slice(separator + 1).trim());
113
331
  }
114
- return undefined;
332
+ return values;
115
333
  }
116
334
  clearBindingCookie(req) {
117
335
  const { res } = req;
@@ -5,3 +5,15 @@ export declare const STATE_COOKIE_SAME_SITE = "lax";
5
5
  export declare const STATE_TTL_MS: number;
6
6
  /** Entropy of the client-held binding value, in bytes. */
7
7
  export declare const BINDING_VALUE_BYTES = 32;
8
+ /**
9
+ * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
10
+ *
11
+ * A client can hold more than one cookie of the same name — a sibling
12
+ * subdomain can set one on the parent domain, and the browser sends every
13
+ * applicable cookie in one header. All of them are tried, so a planted cookie
14
+ * cannot deny login by sorting ahead of the real one; the cap bounds the work
15
+ * an unauthenticated caller can ask for. It is not a brute-force control: the
16
+ * pending record is consumed on recognition, so a state gets one attempt
17
+ * whatever the cap.
18
+ */
19
+ export declare const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/dist/constants.js CHANGED
@@ -14,3 +14,15 @@ export const STATE_COOKIE_SAME_SITE = 'lax';
14
14
  export const STATE_TTL_MS = 10 * 60 * 1000;
15
15
  /** Entropy of the client-held binding value, in bytes. */
16
16
  export const BINDING_VALUE_BYTES = 32;
17
+ /**
18
+ * Most values carrying `STATE_COOKIE_NAME` that a single callback will try.
19
+ *
20
+ * A client can hold more than one cookie of the same name — a sibling
21
+ * subdomain can set one on the parent domain, and the browser sends every
22
+ * applicable cookie in one header. All of them are tried, so a planted cookie
23
+ * cannot deny login by sorting ahead of the real one; the cap bounds the work
24
+ * an unauthenticated caller can ask for. It is not a brute-force control: the
25
+ * pending record is consumed on recognition, so a state gets one attempt
26
+ * whatever the cap.
27
+ */
28
+ export const MAX_BINDING_COOKIE_CANDIDATES = 8;
package/dist/main.d.ts CHANGED
@@ -26,7 +26,19 @@ export default class OAuth {
26
26
  init(): Promise<void>;
27
27
  getProvider(name: string): ProviderEntry;
28
28
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
29
- handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string): Promise<import("./session-manager.js").SessionResult>;
29
+ /**
30
+ * `bindingValues` is required, not optional (#36). An optional parameter lets
31
+ * an existing three-argument call site keep compiling and then fail at
32
+ * runtime on the first real login; a compile error is the loudest disclosure
33
+ * channel available for this break.
34
+ *
35
+ * It is an array, not a single value, because a client can hold more than one
36
+ * cookie of the binding cookie's name and every one of them has to be tried —
37
+ * see `StateStore.anyCandidateMatches`. A caller driving the flow itself
38
+ * passes `[bindingValue]`; the route handler passes through every value the
39
+ * client presented, which may be none.
40
+ */
41
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<import("./session-manager.js").SessionResult>;
30
42
  getSession(sessionId: string): unknown;
31
43
  logout(sessionId: string): void;
32
44
  }
package/dist/main.js CHANGED
@@ -51,8 +51,20 @@ export default class OAuth {
51
51
  const { stateToken, bindingValue } = this.stateStore.issue(providerName);
52
52
  return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
53
53
  }
54
- async handleCallback(providerName, code, stateToken, bindingValue) {
55
- this.stateStore.consume(stateToken, providerName, bindingValue);
54
+ /**
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.
59
+ *
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.
65
+ */
66
+ async handleCallback(providerName, code, stateToken, bindingValues) {
67
+ this.stateStore.consume(stateToken, providerName, bindingValues);
56
68
  const { flow, tokenManager } = this.getProvider(providerName);
57
69
  const tokens = await tokenManager.getTokens(code);
58
70
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);