@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/src/main.ts 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,37 +7,53 @@ 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
  import type OAuthFlow from './oauth-flow.js';
11
11
 
12
12
  setup(['authenticate']);
13
13
 
14
+ /** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
15
+ export const STATE_TTL_MS = 10 * 60 * 1000;
16
+
17
+ /** Entropy of the client-held binding value, in bytes. */
18
+ export const BINDING_VALUE_BYTES = 32;
19
+
14
20
  interface ProviderEntry {
15
21
  flow: OAuthFlow;
16
22
  tokenManager: TokenManager;
17
23
  }
18
24
 
19
- interface ProviderConfig {
20
- module?: string;
21
- [key: string]: unknown;
25
+ /**
26
+ * A flow that is in progress.
27
+ *
28
+ * Holds a *digest* of the binding value rather than the value itself: a
29
+ * callback is only accepted when the caller presents the plaintext that hashes
30
+ * to `bindingHash`, so the record on its own unlocks nothing.
31
+ */
32
+ export interface PendingState {
33
+ bindingHash: string;
34
+ createdAt: number;
22
35
  }
23
36
 
24
- export interface AuthorizationRequest {
25
- /** Provider authorization URL to redirect the client to. */
37
+ export interface IssuedState {
38
+ /** Sent to the provider as the OAuth2 `state` parameter. */
26
39
  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
- */
40
+ /** Retained so a login that cannot be bound can withdraw its own state. */
41
+ stateToken: string;
42
+ /** Held by the client that started the flow, never by the provider. */
32
43
  bindingValue: string;
33
44
  }
34
45
 
46
+ interface ProviderConfig {
47
+ module?: string;
48
+ [key: string]: unknown;
49
+ }
50
+
35
51
  export default class OAuth {
36
52
  static instance: OAuth | null;
37
53
 
38
54
  providers = new Map<string, ProviderEntry>();
39
- stateStore = new StateStore();
55
+ pendingStates = new Map<string, PendingState>();
56
+ stateTtl = STATE_TTL_MS;
40
57
  sessionManager!: SessionManager;
41
58
  frontendCallbackUrl?: string;
42
59
 
@@ -78,33 +95,126 @@ export default class OAuth {
78
95
  return provider;
79
96
  }
80
97
 
81
- getAuthorizationUrl(providerName: string): AuthorizationRequest {
98
+ /**
99
+ * SHA-256 of a binding value, hex encoded.
100
+ *
101
+ * The pending record stores the digest so that read access to the map does
102
+ * not hand over the value a callback must present.
103
+ */
104
+ static hash(value: string): string {
105
+ return createHash('sha256').update(value).digest('hex');
106
+ }
107
+
108
+ /** Length-independent, content-constant-time comparison of two digests. */
109
+ static digestsMatch(a: string, b: string): boolean {
110
+ if (a.length !== b.length) return false;
111
+
112
+ let difference = 0;
113
+ for (let index = 0; index < a.length; index++) {
114
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
115
+ }
116
+
117
+ return difference === 0;
118
+ }
119
+
120
+ /**
121
+ * Whether *any* presented value is the binding value for this record.
122
+ *
123
+ * Every candidate is tried, and the callback is accepted if one matches.
124
+ * Stopping at the first value carrying the cookie's name instead makes a
125
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
126
+ * section 5.4 orders the `Cookie` header by path length then creation time,
127
+ * so an attacker with content control on a sibling subdomain sets a
128
+ * same-named cookie once and every subsequent callback for that victim reads
129
+ * theirs, fails the binding check, and burns the state on the way out. The
130
+ * victim cannot recover by retrying.
131
+ *
132
+ * Accepting any match gives an attacker nothing: they would have to present
133
+ * the victim's own binding value, which is the property being checked. And
134
+ * the candidate list is deliberately uncapped — a cap does not bound an
135
+ * attack, it *is* one, reinstating that denial above its own threshold
136
+ * because the planted cookies are the ones that sort first. The work is
137
+ * already bounded by Node's 16 KB header limit.
138
+ *
139
+ * The reduce does not short-circuit, so the work is a function of how many
140
+ * values were presented and not of which one matched.
141
+ */
142
+ static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean {
143
+ return candidates.reduce(
144
+ (matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched,
145
+ false,
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Starts a flow: an OAuth2 `state` for the provider, and a binding value for
151
+ * the client that asked for it.
152
+ *
153
+ * `state` on its own is replay-window limiting, not the CSRF binding it
154
+ * exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
155
+ * state issued to any visitor validated for any callback, so an attacker
156
+ * could harvest their own state and code, deliver them to a victim over a
157
+ * plain link, and log the victim into the attacker's account. The binding
158
+ * value is the thing the victim's browser carries and the attacker's does
159
+ * not (#36).
160
+ */
161
+ getAuthorizationUrl(providerName: string): IssuedState {
82
162
  const { flow } = this.getProvider(providerName);
83
- const { stateToken, bindingValue } = this.stateStore.issue(providerName);
163
+ const stateToken = randomUUID();
164
+ const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
165
+
166
+ this.pendingStates.set(stateToken, {
167
+ bindingHash: OAuth.hash(bindingValue),
168
+ createdAt: Date.now(),
169
+ });
84
170
 
85
- return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
171
+ return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
86
172
  }
87
173
 
88
174
  /**
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.
175
+ * Withdraws a state that was issued but could not be handed to a client.
93
176
  *
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.
177
+ * Used by the login route when the binding cookie cannot be set: a state the
178
+ * client cannot be bound to is exactly the defect this mechanism exists to
179
+ * prevent, so it must not outlive the request that failed to bind it.
99
180
  */
100
- async handleCallback(
101
- providerName: string,
102
- code: string,
103
- stateToken: string,
104
- bindingValues: readonly string[],
105
- ) {
106
- this.stateStore.consume(stateToken, providerName, bindingValues);
181
+ discardState(stateToken: string): void {
182
+ this.pendingStates.delete(stateToken);
183
+ }
184
+
185
+ /**
186
+ * Validates and consumes a pending state, then completes the flow.
187
+ *
188
+ * `bindingValues` is every value the client presented under the binding
189
+ * cookie's name — see `anyCandidateMatches`.
190
+ */
191
+ async handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]) {
192
+ const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
193
+ if (!record) throw new Error('Invalid or missing state token');
194
+
195
+ // Consumed on recognition, before the TTL and binding checks, so every
196
+ // state gets exactly one attempt whatever the outcome. Checking the
197
+ // binding first would leave the record in place on a mismatch and turn
198
+ // this endpoint into a repeatable, unauthenticated oracle against the
199
+ // binding value for the state's full lifetime.
200
+ this.pendingStates.delete(stateToken);
201
+
202
+ if (Date.now() - record.createdAt > this.stateTtl) {
203
+ throw new Error('State token has expired');
204
+ }
107
205
 
206
+ // No "absent means skip". An empty candidate list is a rejection, which is
207
+ // what makes an attacker-delivered link fail for a victim who never
208
+ // started the flow and therefore holds no binding cookie.
209
+ const candidates = bindingValues.filter(value => value.length > 0);
210
+ if (candidates.length === 0) throw new Error('Missing state binding value');
211
+
212
+ if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
213
+ throw new Error('State token is not bound to this client');
214
+ }
215
+
216
+ // Everything below burns a live authorization code, so the binding is
217
+ // settled before `exchangeCode` is ever reached.
108
218
  const { flow, tokenManager } = this.getProvider(providerName);
109
219
  const tokens = await tokenManager.getTokens(code);
110
220
  const rawUser = await flow.fetchUserInfo(tokens.accessToken);
@@ -113,6 +223,11 @@ export default class OAuth {
113
223
  return this.sessionManager.create(user, tokens);
114
224
  }
115
225
 
226
+ /** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
227
+ redirectUriFor(providerName: string): string | undefined {
228
+ return this.providers.get(providerName)?.flow.redirectUri;
229
+ }
230
+
116
231
  getSession(sessionId: string) {
117
232
  return this.sessionManager.validate(sessionId);
118
233
  }
@@ -1,10 +1,19 @@
1
1
  declare module 'node:crypto' {
2
+ export function randomUUID(): string;
3
+
4
+ /**
5
+ * Structural stand-ins: this repo declares its own node shims rather than
6
+ * depending on `@types/node`, so only the surface actually used is typed.
7
+ */
8
+ interface BinaryLike {
9
+ toString(encoding: string): string;
10
+ }
11
+
2
12
  interface Hash {
3
13
  update(data: string): Hash;
4
- digest(encoding: 'hex'): string;
14
+ digest(encoding: string): string;
5
15
  }
6
16
 
7
- export function randomUUID(): string;
8
- export function randomBytes(size: number): { toString(encoding: 'base64url' | 'hex'): string };
17
+ export function randomBytes(size: number): BinaryLike;
9
18
  export function createHash(algorithm: string): Hash;
10
19
  }
@@ -1,33 +0,0 @@
1
- export declare const STATE_COOKIE_NAME = "stonyx_oauth_state";
2
- export declare const STATE_COOKIE_PATH = "/auth";
3
- export declare const STATE_COOKIE_SAME_SITE = "lax";
4
- /** Lifetime of a pending state record, and the binding cookie's Max-Age. */
5
- export declare const STATE_TTL_MS: number;
6
- /** Entropy of the client-held binding value, in bytes. */
7
- export declare const BINDING_VALUE_BYTES = 32;
8
- /**
9
- * There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
10
- * a callback will try.
11
- *
12
- * A client can hold more than one cookie of the same name — a sibling subdomain
13
- * can set one on the parent domain — and the browser sends every applicable
14
- * cookie in one header, so all of them must be tried or a planted cookie denies
15
- * login by sorting ahead of the real one (RFC 6265 section 5.4).
16
- *
17
- * A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
18
- * threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
19
- * session and 8 failed permanently — the same outcome as the original defect,
20
- * with the attacker's cost raised from one planted cookie to eight. That is
21
- * reachable: RFC 6265 section 5.4 orders by path length then creation time, so
22
- * a 4-label API host with a foothold beneath it gets 3 settable parent domains
23
- * x 3 usable paths = 9 candidates ahead of the real one.
24
- *
25
- * What the cap was defending is already bounded, structurally and for free.
26
- * Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
27
- * and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
28
- * 20 bytes, so a request cannot present more than 779 hashable candidates.
29
- * Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
30
- * runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
31
- * denial of login to avoid a third of a millisecond is the wrong trade, so the
32
- * bound is left where it already was: the header size limit.
33
- */
package/dist/constants.js DELETED
@@ -1,42 +0,0 @@
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
- export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
11
- export const STATE_COOKIE_PATH = '/auth';
12
- export const STATE_COOKIE_SAME_SITE = 'lax';
13
- /** Lifetime of a pending state record, and the binding cookie's Max-Age. */
14
- export const STATE_TTL_MS = 10 * 60 * 1000;
15
- /** Entropy of the client-held binding value, in bytes. */
16
- export const BINDING_VALUE_BYTES = 32;
17
- /**
18
- * There is deliberately no cap on how many values carrying `STATE_COOKIE_NAME`
19
- * a callback will try.
20
- *
21
- * A client can hold more than one cookie of the same name — a sibling subdomain
22
- * can set one on the parent domain — and the browser sends every applicable
23
- * cookie in one header, so all of them must be tried or a planted cookie denies
24
- * login by sorting ahead of the real one (RFC 6265 section 5.4).
25
- *
26
- * A cap of 8 was tried and withdrawn: it *reinstated* that denial above its own
27
- * threshold. Measured on the pre-change tree, 7 shadow cookies still minted a
28
- * session and 8 failed permanently — the same outcome as the original defect,
29
- * with the attacker's cost raised from one planted cookie to eight. That is
30
- * reachable: RFC 6265 section 5.4 orders by path length then creation time, so
31
- * a 4-label API host with a foothold beneath it gets 3 settable parent domains
32
- * x 3 usable paths = 9 candidates ahead of the real one.
33
- *
34
- * What the cap was defending is already bounded, structurally and for free.
35
- * Node caps the whole header block at `http.maxHeaderSize`, 16 KB by default,
36
- * and the shortest segment that can reach the hash is `stonyx_oauth_state=x` at
37
- * 20 bytes, so a request cannot present more than 779 hashable candidates.
38
- * Parsing and SHA-256-hashing all 779 costs 0.32 ms median / 0.81 ms worst of 9
39
- * runs (Node 24.13.0, Apple silicon). Paying a permanent, unauthenticated
40
- * denial of login to avoid a third of a millisecond is the wrong trade, so the
41
- * bound is left where it already was: the header size limit.
42
- */
@@ -1,116 +0,0 @@
1
- /**
2
- * Server-side record for an OAuth flow that is in progress.
3
- *
4
- * Deliberately holds a *digest* of the binding value rather than the value
5
- * itself: a callback is only accepted when the caller presents the plaintext
6
- * that hashes to `bindingHash`, so the record on its own unlocks nothing.
7
- */
8
- export interface PendingState {
9
- provider: string;
10
- bindingHash: string;
11
- createdAt: number;
12
- }
13
- /**
14
- * The five reasons a callback is rejected, as fixed strings.
15
- *
16
- * Named rather than inlined so that collapsing two of them into one is a
17
- * visible edit: distinguishing them in the server log is the whole point of
18
- * logging a reason, and an operator telling an expired state from a
19
- * cross-provider replay depends on them staying distinct.
20
- */
21
- export declare const STATE_REJECTION: {
22
- readonly unknownState: "Invalid or missing state token";
23
- readonly expired: "State token has expired";
24
- readonly wrongProvider: "State token was not issued for this provider";
25
- readonly missingBinding: "Missing state binding value";
26
- readonly unboundClient: "State token is not bound to this client";
27
- };
28
- /**
29
- * A callback rejected by `StateStore.consume`.
30
- *
31
- * Carries two things the route layer cannot otherwise recover: that the
32
- * rejection came from state validation rather than from anything downstream of
33
- * it, and whether a pending record was actually consumed.
34
- */
35
- export declare class StateRejection extends Error {
36
- /** True when this attempt recognised a pending record and burned it. */
37
- consumed: boolean;
38
- constructor(reason: string, consumed: boolean);
39
- }
40
- export interface IssuedState {
41
- /** Sent to the provider as the OAuth2 `state` parameter. */
42
- stateToken: string;
43
- /** Held by the client that started the flow (a cookie), never by the provider. */
44
- bindingValue: string;
45
- }
46
- /**
47
- * Issues and validates OAuth2 `state` tokens bound to the client that started
48
- * the flow (#36).
49
- *
50
- * Presence-plus-age on a process-global map is replay-window limiting, not the
51
- * CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
52
- * issued to any visitor validated for any callback, so an attacker could
53
- * harvest their own state and code and deliver them to a victim, logging the
54
- * victim in as the attacker. A state is now only accepted when the caller also
55
- * presents the matching client-held binding value, and only at the provider it
56
- * was issued for.
57
- */
58
- export default class StateStore {
59
- pending: Map<string, PendingState>;
60
- ttl: number;
61
- constructor(ttl?: number);
62
- static hash(value: string): string;
63
- /** Length-independent, content-constant-time comparison of two digests. */
64
- static digestsMatch(a: string, b: string): boolean;
65
- issue(provider: string): IssuedState;
66
- /**
67
- * Validates and consumes a pending state. Throws on every rejection path.
68
- *
69
- * The record is removed as soon as the state is recognised — before the TTL,
70
- * provider and binding checks — so every state gets exactly one attempt
71
- * whatever the outcome.
72
- *
73
- * That uniformity is the justification, not brute-force resistance:
74
- * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
75
- * not the record survives. What retaining it would buy an attacker is a
76
- * repeatable, unauthenticated oracle on this endpoint for the state's full
77
- * lifetime — and the safety of that would then rest entirely on an entropy
78
- * constant a future change can lower. One attempt per state is a structural
79
- * property; entropy arithmetic is not.
80
- *
81
- * The trade is real: an attacker who already knows a victim's state can burn
82
- * it, and the victim must restart at `/auth/login/:provider`. That vector is
83
- * accepted deliberately — it requires the victim's `randomUUID` state, and
84
- * it is self-healing on retry. `consumed` on the rejection says whether this
85
- * call actually burned a record, so a caller can distinguish "nothing of the
86
- * victim's was touched" from "one attempt was spent".
87
- *
88
- * `bindingValues` is every value the client presented under the binding
89
- * cookie's name, not just the first — see `anyCandidateMatches`.
90
- */
91
- consume(stateToken: string | undefined, provider: string, bindingValues: readonly string[]): void;
92
- /**
93
- * Whether *any* presented value is the binding value for this record.
94
- *
95
- * Every candidate is tried, and the callback is accepted if one matches.
96
- * Returning on the first value carrying the cookie name instead made a
97
- * planted cookie a permanent, unauthenticated denial of login: RFC 6265
98
- * section 5.4 orders the `Cookie` header by path length then creation time,
99
- * so an attacker with content control on a sibling subdomain sets a
100
- * same-named cookie once and every subsequent callback for that victim reads
101
- * theirs, fails the binding check, and burns the state on the way out. The
102
- * victim cannot recover by retrying.
103
- *
104
- * Accepting any match gives an attacker nothing: they would have to present
105
- * the victim's own binding value, which is the property being checked. The
106
- * record is consumed on recognition, so a state still gets exactly one
107
- * attempt however many candidates were presented, and the candidate count is
108
- * bounded by Node's header size limit rather than by a cap here — a cap
109
- * truncates the list from the wrong end and reinstates the denial this method
110
- * exists to close. See `constants.ts`.
111
- *
112
- * The loop does not short-circuit, so the work is a function of how many
113
- * values were presented and not of which one matched.
114
- */
115
- anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean;
116
- }
@@ -1,144 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
- import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
3
- /**
4
- * The five reasons a callback is rejected, as fixed strings.
5
- *
6
- * Named rather than inlined so that collapsing two of them into one is a
7
- * visible edit: distinguishing them in the server log is the whole point of
8
- * logging a reason, and an operator telling an expired state from a
9
- * cross-provider replay depends on them staying distinct.
10
- */
11
- export const STATE_REJECTION = {
12
- unknownState: 'Invalid or missing state token',
13
- expired: 'State token has expired',
14
- wrongProvider: 'State token was not issued for this provider',
15
- missingBinding: 'Missing state binding value',
16
- unboundClient: 'State token is not bound to this client',
17
- };
18
- /**
19
- * A callback rejected by `StateStore.consume`.
20
- *
21
- * Carries two things the route layer cannot otherwise recover: that the
22
- * rejection came from state validation rather than from anything downstream of
23
- * it, and whether a pending record was actually consumed.
24
- */
25
- export class StateRejection extends Error {
26
- /** True when this attempt recognised a pending record and burned it. */
27
- consumed;
28
- constructor(reason, consumed) {
29
- super(reason);
30
- this.name = 'StateRejection';
31
- this.consumed = consumed;
32
- }
33
- }
34
- /**
35
- * Issues and validates OAuth2 `state` tokens bound to the client that started
36
- * the flow (#36).
37
- *
38
- * Presence-plus-age on a process-global map is replay-window limiting, not the
39
- * CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
40
- * issued to any visitor validated for any callback, so an attacker could
41
- * harvest their own state and code and deliver them to a victim, logging the
42
- * victim in as the attacker. A state is now only accepted when the caller also
43
- * presents the matching client-held binding value, and only at the provider it
44
- * was issued for.
45
- */
46
- export default class StateStore {
47
- pending = new Map();
48
- ttl;
49
- constructor(ttl = STATE_TTL_MS) {
50
- this.ttl = ttl;
51
- }
52
- static hash(value) {
53
- return createHash('sha256').update(value).digest('hex');
54
- }
55
- /** Length-independent, content-constant-time comparison of two digests. */
56
- static digestsMatch(a, b) {
57
- if (a.length !== b.length)
58
- return false;
59
- let difference = 0;
60
- for (let index = 0; index < a.length; index++) {
61
- difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
62
- }
63
- return difference === 0;
64
- }
65
- issue(provider) {
66
- const stateToken = randomUUID();
67
- const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
68
- this.pending.set(stateToken, {
69
- provider,
70
- bindingHash: StateStore.hash(bindingValue),
71
- createdAt: Date.now(),
72
- });
73
- return { stateToken, bindingValue };
74
- }
75
- /**
76
- * Validates and consumes a pending state. Throws on every rejection path.
77
- *
78
- * The record is removed as soon as the state is recognised — before the TTL,
79
- * provider and binding checks — so every state gets exactly one attempt
80
- * whatever the outcome.
81
- *
82
- * That uniformity is the justification, not brute-force resistance:
83
- * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
84
- * not the record survives. What retaining it would buy an attacker is a
85
- * repeatable, unauthenticated oracle on this endpoint for the state's full
86
- * lifetime — and the safety of that would then rest entirely on an entropy
87
- * constant a future change can lower. One attempt per state is a structural
88
- * property; entropy arithmetic is not.
89
- *
90
- * The trade is real: an attacker who already knows a victim's state can burn
91
- * it, and the victim must restart at `/auth/login/:provider`. That vector is
92
- * accepted deliberately — it requires the victim's `randomUUID` state, and
93
- * it is self-healing on retry. `consumed` on the rejection says whether this
94
- * call actually burned a record, so a caller can distinguish "nothing of the
95
- * victim's was touched" from "one attempt was spent".
96
- *
97
- * `bindingValues` is every value the client presented under the binding
98
- * cookie's name, not just the first — see `anyCandidateMatches`.
99
- */
100
- consume(stateToken, provider, bindingValues) {
101
- if (!stateToken)
102
- throw new StateRejection(STATE_REJECTION.unknownState, false);
103
- const record = this.pending.get(stateToken);
104
- if (!record)
105
- throw new StateRejection(STATE_REJECTION.unknownState, false);
106
- this.pending.delete(stateToken);
107
- if (Date.now() - record.createdAt > this.ttl)
108
- throw new StateRejection(STATE_REJECTION.expired, true);
109
- if (record.provider !== provider)
110
- throw new StateRejection(STATE_REJECTION.wrongProvider, true);
111
- const candidates = bindingValues.filter(value => value.length > 0);
112
- if (candidates.length === 0)
113
- throw new StateRejection(STATE_REJECTION.missingBinding, true);
114
- if (!this.anyCandidateMatches(candidates, record)) {
115
- throw new StateRejection(STATE_REJECTION.unboundClient, true);
116
- }
117
- }
118
- /**
119
- * Whether *any* presented value is the binding value for this record.
120
- *
121
- * Every candidate is tried, and the callback is accepted if one matches.
122
- * Returning on the first value carrying the cookie name instead made a
123
- * planted cookie a permanent, unauthenticated denial of login: RFC 6265
124
- * section 5.4 orders the `Cookie` header by path length then creation time,
125
- * so an attacker with content control on a sibling subdomain sets a
126
- * same-named cookie once and every subsequent callback for that victim reads
127
- * theirs, fails the binding check, and burns the state on the way out. The
128
- * victim cannot recover by retrying.
129
- *
130
- * Accepting any match gives an attacker nothing: they would have to present
131
- * the victim's own binding value, which is the property being checked. The
132
- * record is consumed on recognition, so a state still gets exactly one
133
- * attempt however many candidates were presented, and the candidate count is
134
- * bounded by Node's header size limit rather than by a cap here — a cap
135
- * truncates the list from the wrong end and reinstates the denial this method
136
- * exists to close. See `constants.ts`.
137
- *
138
- * The loop does not short-circuit, so the work is a function of how many
139
- * values were presented and not of which one matched.
140
- */
141
- anyCandidateMatches(candidates, record) {
142
- return candidates.reduce((matched, candidate) => StateStore.digestsMatch(StateStore.hash(candidate), record.bindingHash) || matched, false);
143
- }
144
- }
package/src/constants.ts DELETED
@@ -1,46 +0,0 @@
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
- */