@stonyx/oauth 0.1.1-alpha.21 → 0.1.1-alpha.22
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 +3 -166
- package/dist/auth-request.d.ts +4 -116
- package/dist/auth-request.js +4 -278
- package/dist/main.d.ts +3 -26
- package/dist/main.js +16 -18
- package/package.json +1 -1
- package/src/auth-request.ts +6 -346
- package/src/main.ts +18 -36
- package/src/types/node.d.ts +0 -7
- package/src/types/stonyx.d.ts +0 -1
- package/dist/constants.d.ts +0 -33
- package/dist/constants.js +0 -42
- package/dist/state-store.d.ts +0 -116
- package/dist/state-store.js +0 -144
- package/src/constants.ts +0 -46
- package/src/state-store.ts +0 -179
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
|
-
*/
|
package/dist/state-store.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/state-store.js
DELETED
|
@@ -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
|
-
*/
|
package/src/state-store.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Server-side record for an OAuth flow that is in progress.
|
|
6
|
-
*
|
|
7
|
-
* Deliberately holds a *digest* of the binding value rather than the value
|
|
8
|
-
* itself: a callback is only accepted when the caller presents the plaintext
|
|
9
|
-
* that hashes to `bindingHash`, so the record on its own unlocks nothing.
|
|
10
|
-
*/
|
|
11
|
-
export interface PendingState {
|
|
12
|
-
provider: string;
|
|
13
|
-
bindingHash: string;
|
|
14
|
-
createdAt: number;
|
|
15
|
-
}
|
|
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
|
-
|
|
51
|
-
export interface IssuedState {
|
|
52
|
-
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
53
|
-
stateToken: string;
|
|
54
|
-
/** Held by the client that started the flow (a cookie), never by the provider. */
|
|
55
|
-
bindingValue: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Issues and validates OAuth2 `state` tokens bound to the client that started
|
|
60
|
-
* the flow (#36).
|
|
61
|
-
*
|
|
62
|
-
* Presence-plus-age on a process-global map is replay-window limiting, not the
|
|
63
|
-
* CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
|
|
64
|
-
* issued to any visitor validated for any callback, so an attacker could
|
|
65
|
-
* harvest their own state and code and deliver them to a victim, logging the
|
|
66
|
-
* victim in as the attacker. A state is now only accepted when the caller also
|
|
67
|
-
* presents the matching client-held binding value, and only at the provider it
|
|
68
|
-
* was issued for.
|
|
69
|
-
*/
|
|
70
|
-
export default class StateStore {
|
|
71
|
-
pending = new Map<string, PendingState>();
|
|
72
|
-
ttl: number;
|
|
73
|
-
|
|
74
|
-
constructor(ttl: number = STATE_TTL_MS) {
|
|
75
|
-
this.ttl = ttl;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
static hash(value: string): string {
|
|
79
|
-
return createHash('sha256').update(value).digest('hex');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Length-independent, content-constant-time comparison of two digests. */
|
|
83
|
-
static digestsMatch(a: string, b: string): boolean {
|
|
84
|
-
if (a.length !== b.length) return false;
|
|
85
|
-
|
|
86
|
-
let difference = 0;
|
|
87
|
-
for (let index = 0; index < a.length; index++) {
|
|
88
|
-
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return difference === 0;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
issue(provider: string): IssuedState {
|
|
95
|
-
const stateToken = randomUUID();
|
|
96
|
-
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
97
|
-
|
|
98
|
-
this.pending.set(stateToken, {
|
|
99
|
-
provider,
|
|
100
|
-
bindingHash: StateStore.hash(bindingValue),
|
|
101
|
-
createdAt: Date.now(),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
return { stateToken, bindingValue };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Validates and consumes a pending state. Throws on every rejection path.
|
|
109
|
-
*
|
|
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`.
|
|
131
|
-
*/
|
|
132
|
-
consume(stateToken: string | undefined, provider: string, bindingValues: readonly string[]): void {
|
|
133
|
-
if (!stateToken) throw new StateRejection(STATE_REJECTION.unknownState, false);
|
|
134
|
-
|
|
135
|
-
const record = this.pending.get(stateToken);
|
|
136
|
-
if (!record) throw new StateRejection(STATE_REJECTION.unknownState, false);
|
|
137
|
-
this.pending.delete(stateToken);
|
|
138
|
-
|
|
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);
|
|
141
|
-
|
|
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);
|
|
147
|
-
}
|
|
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
|
-
* record is consumed on recognition, so a state still gets exactly one
|
|
165
|
-
* attempt however many candidates were presented, and the candidate count is
|
|
166
|
-
* bounded by Node's header size limit rather than by a cap here — a cap
|
|
167
|
-
* truncates the list from the wrong end and reinstates the denial this method
|
|
168
|
-
* exists to close. See `constants.ts`.
|
|
169
|
-
*
|
|
170
|
-
* The loop does not short-circuit, so the work is a function of how many
|
|
171
|
-
* values were presented and not of which one matched.
|
|
172
|
-
*/
|
|
173
|
-
anyCandidateMatches(candidates: readonly string[], record: PendingState): boolean {
|
|
174
|
-
return candidates.reduce(
|
|
175
|
-
(matched, candidate) => StateStore.digestsMatch(StateStore.hash(candidate), record.bindingHash) || matched,
|
|
176
|
-
false,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
}
|