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

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,7 +86,7 @@ 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 (`localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`) | 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 |
90
90
  | `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
91
91
 
92
92
  `GET /auth/callback/:provider` accepts the callback only when all of the
@@ -101,10 +101,35 @@ The state and the cookie are both single-use: the pending record is consumed on
101
101
  any callback that presents a recognised `state` — successful or not — and the
102
102
  callback response clears the cookie.
103
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.
104
+ Two distinct failure modes surface on two different routes. They are unrelated,
105
+ and the route is the fastest way to tell them apart:
106
+
107
+ - **The cookie cannot be set at all.** `GET /auth/login/:provider` responds
108
+ `500` rather than issuing a state it cannot bind, and logs
109
+ `OAuth: unable to set the state binding cookie; login rejected`. This is a
110
+ framework-wiring condition — the response object the module reaches for is
111
+ not there — not a network or proxy one.
112
+ - **`Set-Cookie` is stripped in transit** by a reverse proxy or CDN.
113
+ `GET /auth/login/:provider` **succeeds and redirects normally**; the module
114
+ never learns the header was dropped. The failure surfaces one hop later, at
115
+ `GET /auth/callback/:provider`, as `?error=auth_failed` on
116
+ `frontendCallbackUrl` (or a bare `500` when it is unset), with
117
+ `OAuth: callback rejected — Missing state binding value` in the log. First
118
+ thing to check: does the login response reach the browser carrying
119
+ `Set-Cookie: stonyx_oauth_state`.
120
+
121
+ Every callback rejection is logged server-side with its reason
122
+ (`OAuth: callback rejected — ...`), which distinguishes an unknown state, a
123
+ wrong provider, an expired state, a missing binding value and a wrong binding
124
+ value. The client-facing `auth_failed` stays deliberately opaque.
125
+
126
+ A failed callback **cannot be retried**: the pending record is consumed on any
127
+ callback presenting a recognised `state`, so refreshing the error page or going
128
+ back and forward produces a second `auth_failed`. The user must restart at
129
+ `GET /auth/login/:provider`. Only one login can be in flight per browser at a
130
+ time, for the same reason — the binding cookie has one fixed name, so starting
131
+ a second login overwrites the first flow's binding value and the earlier flow
132
+ will fail at its callback.
108
133
 
109
134
  ### Custom flow drivers
110
135
 
@@ -184,7 +209,11 @@ providers: {
184
209
 
185
210
  Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
186
211
  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.
212
+ than one instance behind a load balancer, will reject the callback. Pending
213
+ records are removed when a callback consumes them, not swept on a timer — the
214
+ ten-minute age bound is only evaluated when a matching callback arrives, so an
215
+ abandoned flow's record persists until the process restarts. See
216
+ [#38](https://github.com/abofs/stonyx-oauth/issues/38).
188
217
 
189
218
  Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
190
219
 
@@ -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, bindingValue: string | undefined): Promise<{
11
11
  sessionId: string;
12
12
  expiresAt: number;
13
13
  }>;
@@ -58,6 +58,23 @@ export default class AuthRequest extends Request {
58
58
  };
59
59
  };
60
60
  cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
61
+ /**
62
+ * Whether the binding cookie is issued with `Secure`.
63
+ *
64
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
65
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
66
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
67
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
68
+ * is therefore `false` on every request to an HTTPS site, and the binding
69
+ * cookie would ship without `Secure` while the deployment looks correct.
70
+ *
71
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
72
+ * wrong there breaks a non-loopback plaintext development setup, which fails
73
+ * at the first login and is loud. The alternative fails silently, in
74
+ * production, on the one attribute protecting the value this whole mechanism
75
+ * is built around.
76
+ */
77
+ isSecureContext(req: RouteRequest): boolean;
61
78
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
62
79
  readBindingCookie(req: RouteRequest): string | undefined;
63
80
  clearBindingCookie(req: RouteRequest): void;
@@ -1,6 +1,11 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  import log from 'stonyx/log';
3
3
  import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
4
+ /**
5
+ * Hosts treated as a development origin, and the only ones exempt from
6
+ * `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
7
+ */
8
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
4
9
  export default class AuthRequest extends Request {
5
10
  oauth;
6
11
  constructor(oauth) {
@@ -36,10 +41,7 @@ export default class AuthRequest extends Request {
36
41
  '/callback/:provider': async (req, state) => {
37
42
  const { provider: providerName } = req.params;
38
43
  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
44
  const bindingValue = this.readBindingCookie(req);
42
- this.clearBindingCookie(req);
43
45
  if (error) {
44
46
  if (this.oauth.frontendCallbackUrl) {
45
47
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
@@ -49,6 +51,13 @@ export default class AuthRequest extends Request {
49
51
  }
50
52
  if (!code)
51
53
  return 400;
54
+ // The binding value is single-use, so this callback is the end of that
55
+ // cookie's life — but only from here down, where the state is actually
56
+ // consumed. Clearing above the two early returns denied login to a
57
+ // client still at the provider's consent screen, via an
58
+ // attacker-induced navigation to `?error=...` that needs no knowledge
59
+ // of the victim's state at all.
60
+ this.clearBindingCookie(req);
52
61
  try {
53
62
  const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
54
63
  if (this.oauth.frontendCallbackUrl) {
@@ -61,7 +70,14 @@ export default class AuthRequest extends Request {
61
70
  }
62
71
  return session;
63
72
  }
64
- catch {
73
+ catch (rejection) {
74
+ // `StateStore.consume` distinguishes five rejection reasons that
75
+ // otherwise collapse into one opaque outcome with no server-side
76
+ // signal at all. The client-facing `auth_failed` stays opaque; the
77
+ // server has no reason to be. The messages are fixed strings, so
78
+ // nothing caller-controlled reaches the log.
79
+ const reason = rejection instanceof Error ? rejection.message : String(rejection);
80
+ log.error(`OAuth: callback rejected — ${reason}`);
65
81
  if (this.oauth.frontendCallbackUrl) {
66
82
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
67
83
  return;
@@ -84,9 +100,43 @@ export default class AuthRequest extends Request {
84
100
  // request and breaks login outright.
85
101
  sameSite: STATE_COOKIE_SAME_SITE,
86
102
  path: STATE_COOKIE_PATH,
87
- secure: req.secure === true,
103
+ secure: this.isSecureContext(req),
88
104
  };
89
105
  }
106
+ /**
107
+ * Whether the binding cookie is issued with `Secure`.
108
+ *
109
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
110
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
111
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
112
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
113
+ * is therefore `false` on every request to an HTTPS site, and the binding
114
+ * cookie would ship without `Secure` while the deployment looks correct.
115
+ *
116
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
117
+ * wrong there breaks a non-loopback plaintext development setup, which fails
118
+ * at the first login and is loud. The alternative fails silently, in
119
+ * production, on the one attribute protecting the value this whole mechanism
120
+ * is built around.
121
+ */
122
+ isSecureContext(req) {
123
+ if (req.secure === true)
124
+ return true;
125
+ const host = req.headers.host;
126
+ if (!host)
127
+ return true;
128
+ // `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
129
+ const hostname = (host.startsWith('[')
130
+ ? host.slice(1, host.indexOf(']'))
131
+ : host.split(':')[0]).toLowerCase();
132
+ if (LOOPBACK_HOSTS.has(hostname))
133
+ return false;
134
+ if (hostname.startsWith('127.'))
135
+ return false;
136
+ if (hostname === 'localhost' || hostname.endsWith('.localhost'))
137
+ return false;
138
+ return true;
139
+ }
90
140
  setBindingCookie(req, bindingValue) {
91
141
  const { res } = req;
92
142
  if (typeof res?.cookie !== 'function') {
@@ -109,7 +159,12 @@ export default class AuthRequest extends Request {
109
159
  continue;
110
160
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
111
161
  continue;
112
- return decodeURIComponent(part.slice(separator + 1).trim());
162
+ // Not decoded. The binding value is base64url, whose alphabet
163
+ // `encodeURIComponent` never escapes, so a decode buys nothing — and
164
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
165
+ // unauthenticated caller can supply, turning the first line of the
166
+ // callback into a 500 with a stack trace.
167
+ return part.slice(separator + 1).trim();
113
168
  }
114
169
  return undefined;
115
170
  }
package/dist/main.d.ts CHANGED
@@ -26,7 +26,15 @@ 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
+ * `bindingValue` 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. It is typed as possibly-undefined
34
+ * because the route handler passes through whatever the client presented,
35
+ * and `StateStore.consume` rejects falsy explicitly.
36
+ */
37
+ handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<import("./session-manager.js").SessionResult>;
30
38
  getSession(sessionId: string): unknown;
31
39
  logout(sessionId: string): void;
32
40
  }
package/dist/main.js CHANGED
@@ -51,6 +51,14 @@ export default class OAuth {
51
51
  const { stateToken, bindingValue } = this.stateStore.issue(providerName);
52
52
  return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
53
53
  }
54
+ /**
55
+ * `bindingValue` 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. It is typed as possibly-undefined
59
+ * because the route handler passes through whatever the client presented,
60
+ * and `StateStore.consume` rejects falsy explicitly.
61
+ */
54
62
  async handleCallback(providerName, code, stateToken, bindingValue) {
55
63
  this.stateStore.consume(stateToken, providerName, bindingValue);
56
64
  const { flow, tokenManager } = this.getProvider(providerName);
@@ -39,9 +39,22 @@ export default class StateStore {
39
39
  /**
40
40
  * Validates and consumes a pending state. Throws on every rejection path.
41
41
  *
42
- * The record is removed as soon as the state is recognised — before the
43
- * binding is checked — so a state cannot survive a failed attempt and be
44
- * used as a target for guessing the binding value.
42
+ * The record is removed as soon as the state is recognised — before the TTL,
43
+ * provider and binding checks — so every state gets exactly one attempt
44
+ * whatever the outcome.
45
+ *
46
+ * That uniformity is the justification, not brute-force resistance:
47
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
48
+ * not the record survives. What retaining it would buy an attacker is a
49
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
50
+ * lifetime — and the safety of that would then rest entirely on an entropy
51
+ * constant a future change can lower. One attempt per state is a structural
52
+ * property; entropy arithmetic is not.
53
+ *
54
+ * The trade is real: an attacker who already knows a victim's state can burn
55
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
56
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
57
+ * it is self-healing on retry.
45
58
  */
46
59
  consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
47
60
  }
@@ -44,9 +44,22 @@ export default class StateStore {
44
44
  /**
45
45
  * Validates and consumes a pending state. Throws on every rejection path.
46
46
  *
47
- * The record is removed as soon as the state is recognised — before the
48
- * binding is checked — so a state cannot survive a failed attempt and be
49
- * used as a target for guessing the binding value.
47
+ * The record is removed as soon as the state is recognised — before the TTL,
48
+ * provider and binding checks — so every state gets exactly one attempt
49
+ * whatever the outcome.
50
+ *
51
+ * That uniformity is the justification, not brute-force resistance:
52
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
53
+ * not the record survives. What retaining it would buy an attacker is a
54
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
55
+ * lifetime — and the safety of that would then rest entirely on an entropy
56
+ * constant a future change can lower. One attempt per state is a structural
57
+ * property; entropy arithmetic is not.
58
+ *
59
+ * The trade is real: an attacker who already knows a victim's state can burn
60
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
61
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
62
+ * it is self-healing on retry.
50
63
  */
51
64
  consume(stateToken, provider, bindingValue) {
52
65
  if (!stateToken)
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-alpha.16",
7
+ "version": "0.1.1-alpha.17",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
@@ -17,10 +17,6 @@
17
17
  "types": "./dist/main.d.ts",
18
18
  "default": "./dist/main.js"
19
19
  },
20
- "./constants": {
21
- "types": "./dist/constants.d.ts",
22
- "default": "./dist/constants.js"
23
- },
24
20
  "./oauth-flow": {
25
21
  "types": "./dist/oauth-flow.d.ts",
26
22
  "default": "./dist/oauth-flow.js"
@@ -65,7 +61,7 @@
65
61
  "@stonyx/rest-server": ">=0.2.1-beta.11"
66
62
  },
67
63
  "devDependencies": {
68
- "@stonyx/rest-server": "0.2.1-beta.80",
64
+ "@stonyx/rest-server": "0.2.1-beta.81",
69
65
  "@stonyx/utils": "0.2.3-beta.26",
70
66
  "@stonyx/logs": "1.0.1-beta.19",
71
67
  "@types/qunit": "^2.19.13",
@@ -12,6 +12,12 @@ interface AuthorizationRequest {
12
12
  bindingValue: string;
13
13
  }
14
14
 
15
+ /**
16
+ * Hosts treated as a development origin, and the only ones exempt from
17
+ * `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
18
+ */
19
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
20
+
15
21
  interface OAuthInstance {
16
22
  frontendCallbackUrl?: string;
17
23
  getSession(sessionId: string): unknown;
@@ -20,7 +26,7 @@ interface OAuthInstance {
20
26
  providerName: string,
21
27
  code: string,
22
28
  stateToken: string,
23
- bindingValue?: string,
29
+ bindingValue: string | undefined,
24
30
  ): Promise<{ sessionId: string; expiresAt: number }>;
25
31
  logout(sessionId: string): void;
26
32
  }
@@ -100,10 +106,7 @@ export default class AuthRequest extends Request {
100
106
  const { provider: providerName } = req.params;
101
107
  const { code, state: stateToken, error } = req.query;
102
108
 
103
- // The binding value is single-use: whatever the outcome below, this
104
- // callback is the end of that cookie's life.
105
109
  const bindingValue = this.readBindingCookie(req);
106
- this.clearBindingCookie(req);
107
110
 
108
111
  if (error) {
109
112
  if (this.oauth.frontendCallbackUrl) {
@@ -115,6 +118,14 @@ export default class AuthRequest extends Request {
115
118
 
116
119
  if (!code) return 400;
117
120
 
121
+ // The binding value is single-use, so this callback is the end of that
122
+ // cookie's life — but only from here down, where the state is actually
123
+ // consumed. Clearing above the two early returns denied login to a
124
+ // client still at the provider's consent screen, via an
125
+ // attacker-induced navigation to `?error=...` that needs no knowledge
126
+ // of the victim's state at all.
127
+ this.clearBindingCookie(req);
128
+
118
129
  try {
119
130
  const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
120
131
 
@@ -128,7 +139,15 @@ export default class AuthRequest extends Request {
128
139
  }
129
140
 
130
141
  return session;
131
- } catch {
142
+ } catch (rejection) {
143
+ // `StateStore.consume` distinguishes five rejection reasons that
144
+ // otherwise collapse into one opaque outcome with no server-side
145
+ // signal at all. The client-facing `auth_failed` stays opaque; the
146
+ // server has no reason to be. The messages are fixed strings, so
147
+ // nothing caller-controlled reaches the log.
148
+ const reason = rejection instanceof Error ? rejection.message : String(rejection);
149
+ log.error(`OAuth: callback rejected — ${reason}`);
150
+
132
151
  if (this.oauth.frontendCallbackUrl) {
133
152
  state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
134
153
  return;
@@ -152,10 +171,45 @@ export default class AuthRequest extends Request {
152
171
  // request and breaks login outright.
153
172
  sameSite: STATE_COOKIE_SAME_SITE,
154
173
  path: STATE_COOKIE_PATH,
155
- secure: req.secure === true,
174
+ secure: this.isSecureContext(req),
156
175
  };
157
176
  }
158
177
 
178
+ /**
179
+ * Whether the binding cookie is issued with `Secure`.
180
+ *
181
+ * Not `req.secure`. Express derives that from the socket unless `trust proxy`
182
+ * is enabled, and `@stonyx/rest-server` leaves it off by default
183
+ * (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
184
+ * topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
185
+ * is therefore `false` on every request to an HTTPS site, and the binding
186
+ * cookie would ship without `Secure` while the deployment looks correct.
187
+ *
188
+ * So `Secure` is set unconditionally except on a loopback host. Guessing
189
+ * wrong there breaks a non-loopback plaintext development setup, which fails
190
+ * at the first login and is loud. The alternative fails silently, in
191
+ * production, on the one attribute protecting the value this whole mechanism
192
+ * is built around.
193
+ */
194
+ isSecureContext(req: RouteRequest): boolean {
195
+ if (req.secure === true) return true;
196
+
197
+ const host = req.headers.host;
198
+ if (!host) return true;
199
+
200
+ // `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
201
+ const hostname = (host.startsWith('[')
202
+ ? host.slice(1, host.indexOf(']'))
203
+ : host.split(':')[0]
204
+ ).toLowerCase();
205
+
206
+ if (LOOPBACK_HOSTS.has(hostname)) return false;
207
+ if (hostname.startsWith('127.')) return false;
208
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) return false;
209
+
210
+ return true;
211
+ }
212
+
159
213
  setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
160
214
  const { res } = req;
161
215
 
@@ -181,7 +235,12 @@ export default class AuthRequest extends Request {
181
235
  if (separator === -1) continue;
182
236
  if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
183
237
 
184
- return decodeURIComponent(part.slice(separator + 1).trim());
238
+ // Not decoded. The binding value is base64url, whose alphabet
239
+ // `encodeURIComponent` never escapes, so a decode buys nothing — and
240
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
241
+ // unauthenticated caller can supply, turning the first line of the
242
+ // callback into a 500 with a stack trace.
243
+ return part.slice(separator + 1).trim();
185
244
  }
186
245
 
187
246
  return undefined;
package/src/main.ts CHANGED
@@ -85,7 +85,20 @@ export default class OAuth {
85
85
  return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
86
86
  }
87
87
 
88
- async handleCallback(providerName: string, code: string, stateToken: string, bindingValue?: string) {
88
+ /**
89
+ * `bindingValue` 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. It is typed as possibly-undefined
93
+ * because the route handler passes through whatever the client presented,
94
+ * and `StateStore.consume` rejects falsy explicitly.
95
+ */
96
+ async handleCallback(
97
+ providerName: string,
98
+ code: string,
99
+ stateToken: string,
100
+ bindingValue: string | undefined,
101
+ ) {
89
102
  this.stateStore.consume(stateToken, providerName, bindingValue);
90
103
 
91
104
  const { flow, tokenManager } = this.getProvider(providerName);
@@ -73,9 +73,22 @@ export default class StateStore {
73
73
  /**
74
74
  * Validates and consumes a pending state. Throws on every rejection path.
75
75
  *
76
- * The record is removed as soon as the state is recognised — before the
77
- * binding is checked — so a state cannot survive a failed attempt and be
78
- * used as a target for guessing the binding value.
76
+ * The record is removed as soon as the state is recognised — before the TTL,
77
+ * provider and binding checks — so every state gets exactly one attempt
78
+ * whatever the outcome.
79
+ *
80
+ * That uniformity is the justification, not brute-force resistance:
81
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
82
+ * not the record survives. What retaining it would buy an attacker is a
83
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
84
+ * lifetime — and the safety of that would then rest entirely on an entropy
85
+ * constant a future change can lower. One attempt per state is a structural
86
+ * property; entropy arithmetic is not.
87
+ *
88
+ * The trade is real: an attacker who already knows a victim's state can burn
89
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
90
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
91
+ * it is self-healing on retry.
79
92
  */
80
93
  consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
81
94
  if (!stateToken) throw new Error('Invalid or missing state token');