@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.
@@ -14,6 +14,40 @@ export interface PendingState {
14
14
  createdAt: number;
15
15
  }
16
16
 
17
+ /**
18
+ * The five reasons a callback is rejected, as fixed strings.
19
+ *
20
+ * Named rather than inlined so that collapsing two of them into one is a
21
+ * visible edit: distinguishing them in the server log is the whole point of
22
+ * logging a reason, and an operator telling an expired state from a
23
+ * cross-provider replay depends on them staying distinct.
24
+ */
25
+ export const STATE_REJECTION = {
26
+ unknownState: 'Invalid or missing state token',
27
+ expired: 'State token has expired',
28
+ wrongProvider: 'State token was not issued for this provider',
29
+ missingBinding: 'Missing state binding value',
30
+ unboundClient: 'State token is not bound to this client',
31
+ } as const;
32
+
33
+ /**
34
+ * A callback rejected by `StateStore.consume`.
35
+ *
36
+ * Carries two things the route layer cannot otherwise recover: that the
37
+ * rejection came from state validation rather than from anything downstream of
38
+ * it, and whether a pending record was actually consumed.
39
+ */
40
+ export class StateRejection extends Error {
41
+ /** True when this attempt recognised a pending record and burned it. */
42
+ consumed: boolean;
43
+
44
+ constructor(reason: string, consumed: boolean) {
45
+ super(reason);
46
+ this.name = 'StateRejection';
47
+ this.consumed = consumed;
48
+ }
49
+ }
50
+
17
51
  export interface IssuedState {
18
52
  /** Sent to the provider as the OAuth2 `state` parameter. */
19
53
  stateToken: string;
@@ -73,23 +107,71 @@ export default class StateStore {
73
107
  /**
74
108
  * Validates and consumes a pending state. Throws on every rejection path.
75
109
  *
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.
110
+ * The record is removed as soon as the state is recognised — before the TTL,
111
+ * provider and binding checks — so every state gets exactly one attempt
112
+ * whatever the outcome.
113
+ *
114
+ * That uniformity is the justification, not brute-force resistance:
115
+ * guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
116
+ * not the record survives. What retaining it would buy an attacker is a
117
+ * repeatable, unauthenticated oracle on this endpoint for the state's full
118
+ * lifetime — and the safety of that would then rest entirely on an entropy
119
+ * constant a future change can lower. One attempt per state is a structural
120
+ * property; entropy arithmetic is not.
121
+ *
122
+ * The trade is real: an attacker who already knows a victim's state can burn
123
+ * it, and the victim must restart at `/auth/login/:provider`. That vector is
124
+ * accepted deliberately — it requires the victim's `randomUUID` state, and
125
+ * it is self-healing on retry. `consumed` on the rejection says whether this
126
+ * call actually burned a record, so a caller can distinguish "nothing of the
127
+ * victim's was touched" from "one attempt was spent".
128
+ *
129
+ * `bindingValues` is every value the client presented under the binding
130
+ * cookie's name, not just the first — see `anyCandidateMatches`.
79
131
  */
80
- consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
81
- if (!stateToken) throw new Error('Invalid or missing state token');
132
+ consume(stateToken: string | undefined, provider: string, bindingValues: readonly string[]): void {
133
+ if (!stateToken) throw new StateRejection(STATE_REJECTION.unknownState, false);
82
134
 
83
135
  const record = this.pending.get(stateToken);
84
- if (!record) throw new Error('Invalid or missing state token');
136
+ if (!record) throw new StateRejection(STATE_REJECTION.unknownState, false);
85
137
  this.pending.delete(stateToken);
86
138
 
87
- if (Date.now() - record.createdAt > this.ttl) throw new Error('State token has expired');
88
- if (record.provider !== provider) throw new Error('State token was not issued for this provider');
89
- if (!bindingValue) throw new Error('Missing state binding value');
139
+ if (Date.now() - record.createdAt > this.ttl) throw new StateRejection(STATE_REJECTION.expired, true);
140
+ if (record.provider !== provider) throw new StateRejection(STATE_REJECTION.wrongProvider, true);
90
141
 
91
- if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
92
- throw new Error('State token is not bound to this client');
142
+ const candidates = bindingValues.filter(value => value.length > 0);
143
+ if (candidates.length === 0) throw new StateRejection(STATE_REJECTION.missingBinding, true);
144
+
145
+ if (!this.anyCandidateMatches(candidates, record)) {
146
+ throw new StateRejection(STATE_REJECTION.unboundClient, true);
93
147
  }
94
148
  }
149
+
150
+ /**
151
+ * Whether *any* presented value is the binding value for this record.
152
+ *
153
+ * Every candidate is tried, and the callback is accepted if one matches.
154
+ * Returning on the first value carrying the cookie name instead made a
155
+ * planted cookie a permanent, unauthenticated denial of login: RFC 6265
156
+ * section 5.4 orders the `Cookie` header by path length then creation time,
157
+ * so an attacker with content control on a sibling subdomain sets a
158
+ * same-named cookie once and every subsequent callback for that victim reads
159
+ * theirs, fails the binding check, and burns the state on the way out. The
160
+ * victim cannot recover by retrying.
161
+ *
162
+ * Accepting any match gives an attacker nothing: they would have to present
163
+ * the victim's own binding value, which is the property being checked. The
164
+ * candidate list is bounded by `MAX_BINDING_COOKIE_CANDIDATES` at the point
165
+ * it is parsed, and the record is consumed on recognition, so a state still
166
+ * gets exactly one attempt.
167
+ *
168
+ * The loop does not short-circuit, so the work is a function of how many
169
+ * values were presented and not of which one matched.
170
+ */
171
+ anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean {
172
+ return candidates.reduce(
173
+ (matched, candidate) => StateStore.digestsMatch(StateStore.hash(candidate), record.bindingHash) || matched,
174
+ false,
175
+ );
176
+ }
95
177
  }