@stonyx/oauth 0.1.1-beta.164 → 0.1.1-beta.166
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 +68 -0
- package/dist/auth-request.d.ts +66 -4
- package/dist/auth-request.js +122 -3
- package/dist/main.d.ts +87 -3
- package/dist/main.js +109 -11
- package/package.json +1 -1
- package/src/auth-request.ts +182 -5
- package/src/main.ts +146 -13
- package/src/types/node.d.ts +16 -0
- package/src/types/stonyx.d.ts +1 -0
package/README.md
CHANGED
|
@@ -116,6 +116,74 @@ providers: {
|
|
|
116
116
|
}
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
## Login CSRF protection — the `oauth_state` cookie
|
|
120
|
+
|
|
121
|
+
### Breaking changes
|
|
122
|
+
|
|
123
|
+
**As of the fix for [#36](https://github.com/abofs/stonyx-oauth/issues/36).** Two separate breaks — an integration can hit either one independently.
|
|
124
|
+
|
|
125
|
+
**1. The login flow now requires a cookie jar.** A client that cannot hold a cookie between `/auth/login/:provider` and `/auth/callback/:provider` can no longer complete a login. That is the point of the change — see [Migration](#migration-from-a-cookie-less-client) below.
|
|
126
|
+
|
|
127
|
+
**2. The JS API changed shape.** This one is invisible to anyone who only reads the cookie disclosure above. If you import the module's default export and call it directly — wrapping it, monkeypatching it, or driving it in tests — three things changed:
|
|
128
|
+
|
|
129
|
+
| | Before | After |
|
|
130
|
+
|---|--------|-------|
|
|
131
|
+
| `getAuthorizationUrl(provider)` | returns the authorization URL as a `string` | returns `{ url, stateToken, bindingValue }` |
|
|
132
|
+
| `handleCallback(provider, code, state)` | three arguments | requires a fourth, `bindingValues: readonly string[]` — every value the caller presented under the `oauth_state` cookie name |
|
|
133
|
+
| `pendingStates` values | `number` (a creation timestamp) | `{ bindingHash, createdAt }` |
|
|
134
|
+
|
|
135
|
+
None of these throws at import time, and a `typeof … === 'function'` surface check passes on all three: the arity and the return type change, not the presence. Callers must be updated by inspection.
|
|
136
|
+
|
|
137
|
+
The HTTP contract is otherwise unchanged — the [route table](#routes) is the same, no config key was added or changed, and both routes still `302` on their success paths. One status is new: `/auth/login/:provider` returns `500` when the binding cookie cannot be set, which it never did before (see below).
|
|
138
|
+
|
|
139
|
+
`GET /auth/login/:provider` issues an `oauth_state` cookie carrying a per-flow binding value, and keeps only its SHA-256 server-side. `GET /auth/callback/:provider` accepts an OAuth2 `state` only from a caller that also presents the matching cookie value.
|
|
140
|
+
|
|
141
|
+
Without it, `state` was verified by membership in a server-side map plus an age bound, and nothing else. There was no value the browser that started the flow carried that another browser did not, so an attacker could start a login, harvest their own `state` and `code`, deliver them to a victim over a plain link, and log that victim into the *attacker's* account (RFC 6749 §10.12, RFC 9700). The victim's own account and data are not exposed; what is at risk is whatever they author afterwards, believing the session is theirs.
|
|
142
|
+
|
|
143
|
+
### Cookie attributes
|
|
144
|
+
|
|
145
|
+
| Attribute | Value | Why |
|
|
146
|
+
|-----------|-------|-----|
|
|
147
|
+
| Name | `oauth_state` | Issued at login, cleared on a successful callback. The *state* is single-use; the cookie name is fixed, so it is not — see [Concurrent logins](#concurrent-logins-in-the-same-browser) |
|
|
148
|
+
| `HttpOnly` | always | Script must not be able to read or forge the binding value |
|
|
149
|
+
| `SameSite` | `Lax` | **Required.** The callback is a cross-site, top-level GET navigation from the provider. `Strict` withholds the cookie on exactly that request and breaks every login |
|
|
150
|
+
| `Path` | `/` | Routing is case-insensitive; RFC 6265 `Path` matching is not. A narrower path silently drops the cookie on a case-varied callback |
|
|
151
|
+
| `Secure` | when the provider's `redirectUri` is not `http:` | Derived from your configured redirect URI, so plaintext local development works and a TLS deployment behind a terminating proxy still gets `Secure` |
|
|
152
|
+
| `Max-Age` | 600 seconds | Matches the server-side state TTL |
|
|
153
|
+
|
|
154
|
+
If the runtime cannot set the cookie, `/auth/login/:provider` returns `500` and issues no state, rather than issuing one that cannot be bound.
|
|
155
|
+
|
|
156
|
+
### Requirements for consumers
|
|
157
|
+
|
|
158
|
+
- **Start the login as a top-level navigation** (`window.location = '/auth/login/discord'`, or a plain link). This is the documented pattern and it avoids CORS entirely.
|
|
159
|
+
- **Serve login and callback from the same host.** The cookie is host-scoped and carries no `Domain` attribute. A **different port on the same host is fine** — port is not part of cookie scope (RFC 6265 §8.5) — but a different *hostname* is not: the cookie is never sent and the login fails. Both routes are mounted on the same `AuthRequest`, so this only bites when something in front of the app splits them across hostnames (a proxy split, or `app.example.com` for login and `example.com` for the callback).
|
|
160
|
+
- **Keep your configured `redirectUri` on the same scheme the login endpoint is served over.** `Secure` is derived from `redirectUri`, so an `https` `redirectUri` behind a plaintext login endpoint issues a `Secure` cookie that the browser silently discards. Every login then fails the binding check **with no server-side signal** — the callback simply reports `error=auth_failed` if `frontendCallbackUrl` is configured, or a bare `500` if it is not. Check this first if logins start failing after a TLS or proxy change.
|
|
161
|
+
- An XHR-initiated login will not work: `@stonyx/rest-server` never passes `credentials: true` to CORS, so the browser will neither store nor send the cookie on a cross-origin XHR. Narrowing `REST_CORS_ORIGIN` from its `*` default does not change this.
|
|
162
|
+
|
|
163
|
+
### Migration from a cookie-less client
|
|
164
|
+
|
|
165
|
+
Scripted and server-to-server logins break. If you drive the flow yourself, carry the `Set-Cookie` from the login response back as a `Cookie` header on the callback:
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
const login = await fetch(`${host}/auth/login/discord`, { redirect: 'manual' });
|
|
169
|
+
const cookie = login.headers.getSetCookie().map(header => header.split(';')[0]).join('; ');
|
|
170
|
+
const state = new URL(login.headers.get('location')).searchParams.get('state');
|
|
171
|
+
|
|
172
|
+
// ...provider redirects back with `code`...
|
|
173
|
+
await fetch(`${host}/auth/callback/discord?code=${code}&state=${state}`, {
|
|
174
|
+
redirect: 'manual',
|
|
175
|
+
headers: { cookie },
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
In a browser, `fetch` needs `credentials: 'include'` for a cross-origin request — but see the CORS caveat above; a top-level navigation is the supported path.
|
|
180
|
+
|
|
181
|
+
### Concurrent logins in the same browser
|
|
182
|
+
|
|
183
|
+
The cookie name is fixed and its `Path` is `/`, so a second login started in the same browser overwrites the first tab's binding value. The first tab's callback then presents the second tab's value and fails the binding check — redirecting with `error=auth_failed` if `frontendCallbackUrl` is configured, or returning `500` if it is not.
|
|
184
|
+
|
|
185
|
+
This fails closed — no session is minted for the wrong flow, and it is not a way past the binding — but it is an availability regression against the previous behaviour, where two concurrent logins both completed. A user who opens two login tabs has to finish in the one they started last, or retry.
|
|
186
|
+
|
|
119
187
|
## Session Management
|
|
120
188
|
|
|
121
189
|
Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
interface AuthorizationRequest {
|
|
3
|
+
url: string;
|
|
4
|
+
stateToken: string;
|
|
5
|
+
bindingValue: string;
|
|
6
|
+
}
|
|
2
7
|
interface OAuthInstance {
|
|
3
8
|
frontendCallbackUrl?: string;
|
|
9
|
+
stateTtl: number;
|
|
4
10
|
getSession(sessionId: string): unknown;
|
|
5
|
-
getAuthorizationUrl(providerName: string):
|
|
6
|
-
|
|
11
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
12
|
+
discardState(stateToken: string): void;
|
|
13
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
14
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<{
|
|
7
15
|
sessionId: string;
|
|
8
16
|
expiresAt: number;
|
|
9
17
|
}>;
|
|
10
18
|
logout(sessionId: string): void;
|
|
11
19
|
}
|
|
20
|
+
export interface CookieOptions {
|
|
21
|
+
httpOnly: boolean;
|
|
22
|
+
sameSite: string;
|
|
23
|
+
path: string;
|
|
24
|
+
secure: boolean;
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The response object express hangs off the request.
|
|
29
|
+
*
|
|
30
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
|
|
31
|
+
* is unreachable once `state.redirect` is set (`request.ts` returns on the
|
|
32
|
+
* redirect first), so setting a cookie means reaching for `req.res`.
|
|
33
|
+
*
|
|
34
|
+
* This is a deliberate, sanctioned interim reach-around, not an accident:
|
|
35
|
+
* `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
|
|
36
|
+
* first-class header/cookie affordance to migrate onto, and it is sequenced
|
|
37
|
+
* after this fix. `setBindingCookie` fails closed if the affordance is not
|
|
38
|
+
* there, which is what contains the dependency.
|
|
39
|
+
*/
|
|
40
|
+
interface ResponseLike {
|
|
41
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
42
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
43
|
+
}
|
|
12
44
|
interface RouteRequest {
|
|
13
45
|
headers: Record<string, string | undefined>;
|
|
14
46
|
params: Record<string, string>;
|
|
15
47
|
query: Record<string, string>;
|
|
48
|
+
res?: ResponseLike;
|
|
16
49
|
}
|
|
17
50
|
interface RouteState {
|
|
18
51
|
redirect?: string;
|
|
@@ -23,13 +56,42 @@ export default class AuthRequest extends Request {
|
|
|
23
56
|
handlers: {
|
|
24
57
|
get: {
|
|
25
58
|
'/': ({ headers }: RouteRequest) => {};
|
|
26
|
-
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 | undefined;
|
|
59
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 | 500 | undefined;
|
|
27
60
|
'/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
|
|
28
61
|
sessionId: string;
|
|
29
62
|
expiresAt: number;
|
|
30
|
-
} |
|
|
63
|
+
} | 500 | 400 | undefined>;
|
|
31
64
|
'/logout': ({ headers }: RouteRequest) => void;
|
|
32
65
|
};
|
|
33
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
69
|
+
*
|
|
70
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
71
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
72
|
+
* a round trip to.
|
|
73
|
+
*
|
|
74
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
75
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
76
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
77
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
78
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
79
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
80
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
81
|
+
*
|
|
82
|
+
* An unparseable or absent redirect URI fails secure.
|
|
83
|
+
*/
|
|
84
|
+
isSecureContext(providerName: string): boolean;
|
|
85
|
+
cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'>;
|
|
86
|
+
setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Every value the client presented under the binding cookie's name.
|
|
89
|
+
*
|
|
90
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
91
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
92
|
+
* for any victim they can plant a same-named cookie on.
|
|
93
|
+
*/
|
|
94
|
+
readBindingCookies(req: RouteRequest): string[];
|
|
95
|
+
clearBindingCookie(req: RouteRequest, providerName: string): void;
|
|
34
96
|
}
|
|
35
97
|
export {};
|
package/dist/auth-request.js
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
/**
|
|
4
|
+
* The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
|
|
5
|
+
*
|
|
6
|
+
* The attributes below are load-bearing, not cosmetic:
|
|
7
|
+
*
|
|
8
|
+
* - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
|
|
9
|
+
* initiated by the provider. `Strict` withholds the cookie on exactly that
|
|
10
|
+
* request, breaking 100% of logins while passing every CSRF test; `None`
|
|
11
|
+
* requires `Secure` and widens exposure for no benefit.
|
|
12
|
+
* - `Path=/` — routing is case-insensitive today
|
|
13
|
+
* (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
|
|
14
|
+
* RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
|
|
15
|
+
* `/auth` silently drops the cookie on a case-varied callback and breaks
|
|
16
|
+
* login.
|
|
17
|
+
* - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
18
|
+
*/
|
|
19
|
+
const STATE_COOKIE_NAME = 'oauth_state';
|
|
20
|
+
const STATE_COOKIE_PATH = '/';
|
|
21
|
+
const STATE_COOKIE_SAME_SITE = 'lax';
|
|
2
22
|
export default class AuthRequest extends Request {
|
|
3
23
|
oauth;
|
|
4
24
|
constructor(oauth) {
|
|
@@ -18,13 +38,21 @@ export default class AuthRequest extends Request {
|
|
|
18
38
|
},
|
|
19
39
|
'/login/:provider': (req, state) => {
|
|
20
40
|
const { provider: providerName } = req.params;
|
|
41
|
+
let authorization;
|
|
21
42
|
try {
|
|
22
|
-
|
|
23
|
-
state.redirect = url;
|
|
43
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
24
44
|
}
|
|
25
45
|
catch {
|
|
26
46
|
return 404;
|
|
27
47
|
}
|
|
48
|
+
// Fail closed. A state we cannot bind to this client is exactly the
|
|
49
|
+
// defect this mechanism exists to prevent, so it is withdrawn rather
|
|
50
|
+
// than issued unbindable.
|
|
51
|
+
if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
|
|
52
|
+
this.oauth.discardState(authorization.stateToken);
|
|
53
|
+
return 500;
|
|
54
|
+
}
|
|
55
|
+
state.redirect = authorization.url;
|
|
28
56
|
},
|
|
29
57
|
'/callback/:provider': async (req, state) => {
|
|
30
58
|
const { provider: providerName } = req.params;
|
|
@@ -39,7 +67,17 @@ export default class AuthRequest extends Request {
|
|
|
39
67
|
if (!code)
|
|
40
68
|
return 400;
|
|
41
69
|
try {
|
|
42
|
-
const session = await this.oauth.handleCallback(providerName, code, stateToken);
|
|
70
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken, this.readBindingCookies(req));
|
|
71
|
+
// Cleared only here, on the success path, which is the only path that
|
|
72
|
+
// is certain to have consumed a state belonging to *this* client.
|
|
73
|
+
//
|
|
74
|
+
// Clearing on failure instead looks harmless and is not: `code` is
|
|
75
|
+
// attacker-supplied and unvalidated, so a bare `?code=1` — no
|
|
76
|
+
// knowledge of anyone's state — would delete the binding cookie of a
|
|
77
|
+
// client still sitting on the provider's consent screen, leaving
|
|
78
|
+
// their pending state untouched so nothing is detectable
|
|
79
|
+
// server-side, and their real callback then fails.
|
|
80
|
+
this.clearBindingCookie(req, providerName);
|
|
43
81
|
if (this.oauth.frontendCallbackUrl) {
|
|
44
82
|
const params = new URLSearchParams({
|
|
45
83
|
sessionId: session.sessionId,
|
|
@@ -65,4 +103,85 @@ export default class AuthRequest extends Request {
|
|
|
65
103
|
},
|
|
66
104
|
}
|
|
67
105
|
};
|
|
106
|
+
/**
|
|
107
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
108
|
+
*
|
|
109
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
110
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
111
|
+
* a round trip to.
|
|
112
|
+
*
|
|
113
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
114
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
115
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
116
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
117
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
118
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
119
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
120
|
+
*
|
|
121
|
+
* An unparseable or absent redirect URI fails secure.
|
|
122
|
+
*/
|
|
123
|
+
isSecureContext(providerName) {
|
|
124
|
+
const redirectUri = this.oauth.redirectUriFor(providerName);
|
|
125
|
+
if (!redirectUri)
|
|
126
|
+
return true;
|
|
127
|
+
try {
|
|
128
|
+
return new URL(redirectUri).protocol !== 'http:';
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
cookieOptions(providerName) {
|
|
135
|
+
return {
|
|
136
|
+
httpOnly: true,
|
|
137
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
138
|
+
path: STATE_COOKIE_PATH,
|
|
139
|
+
secure: this.isSecureContext(providerName),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
setBindingCookie(req, providerName, bindingValue) {
|
|
143
|
+
const { res } = req;
|
|
144
|
+
if (typeof res?.cookie !== 'function') {
|
|
145
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
149
|
+
...this.cookieOptions(providerName),
|
|
150
|
+
maxAge: this.oauth.stateTtl,
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Every value the client presented under the binding cookie's name.
|
|
156
|
+
*
|
|
157
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
158
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
159
|
+
* for any victim they can plant a same-named cookie on.
|
|
160
|
+
*/
|
|
161
|
+
readBindingCookies(req) {
|
|
162
|
+
const header = req.headers.cookie;
|
|
163
|
+
if (!header)
|
|
164
|
+
return [];
|
|
165
|
+
const values = [];
|
|
166
|
+
for (const part of header.split(';')) {
|
|
167
|
+
const separator = part.indexOf('=');
|
|
168
|
+
if (separator === -1)
|
|
169
|
+
continue;
|
|
170
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
|
|
171
|
+
continue;
|
|
172
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
173
|
+
// `encodeURIComponent` never escapes, so decoding buys nothing — and
|
|
174
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
175
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
176
|
+
// callback into a 500.
|
|
177
|
+
values.push(part.slice(separator + 1).trim());
|
|
178
|
+
}
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
181
|
+
clearBindingCookie(req, providerName) {
|
|
182
|
+
const { res } = req;
|
|
183
|
+
if (typeof res?.clearCookie !== 'function')
|
|
184
|
+
return;
|
|
185
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
|
|
186
|
+
}
|
|
68
187
|
}
|
package/dist/main.d.ts
CHANGED
|
@@ -1,21 +1,105 @@
|
|
|
1
1
|
import TokenManager from './token-manager.js';
|
|
2
2
|
import SessionManager from './session-manager.js';
|
|
3
3
|
import type OAuthFlow from './oauth-flow.js';
|
|
4
|
+
/** Lifetime of a pending state, 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;
|
|
4
8
|
interface ProviderEntry {
|
|
5
9
|
flow: OAuthFlow;
|
|
6
10
|
tokenManager: TokenManager;
|
|
7
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* A flow that is in progress.
|
|
14
|
+
*
|
|
15
|
+
* Holds a *digest* of the binding value rather than the value itself: a
|
|
16
|
+
* callback is only accepted when the caller presents the plaintext that hashes
|
|
17
|
+
* to `bindingHash`, so the record on its own unlocks nothing.
|
|
18
|
+
*/
|
|
19
|
+
export interface PendingState {
|
|
20
|
+
bindingHash: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
export interface IssuedState {
|
|
24
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Retained so a login that cannot be bound can withdraw its own state. */
|
|
27
|
+
stateToken: string;
|
|
28
|
+
/** Held by the client that started the flow, never by the provider. */
|
|
29
|
+
bindingValue: string;
|
|
30
|
+
}
|
|
8
31
|
export default class OAuth {
|
|
9
32
|
static instance: OAuth | null;
|
|
10
33
|
providers: Map<string, ProviderEntry>;
|
|
11
|
-
pendingStates: Map<string,
|
|
34
|
+
pendingStates: Map<string, PendingState>;
|
|
35
|
+
stateTtl: number;
|
|
12
36
|
sessionManager: SessionManager;
|
|
13
37
|
frontendCallbackUrl?: string;
|
|
14
38
|
constructor();
|
|
15
39
|
init(): Promise<void>;
|
|
16
40
|
getProvider(name: string): ProviderEntry;
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
/**
|
|
42
|
+
* SHA-256 of a binding value, hex encoded.
|
|
43
|
+
*
|
|
44
|
+
* The pending record stores the digest so that read access to the map does
|
|
45
|
+
* not hand over the value a callback must present.
|
|
46
|
+
*/
|
|
47
|
+
static hash(value: string): string;
|
|
48
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
49
|
+
static digestsMatch(a: string, b: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether *any* presented value is the binding value for this record.
|
|
52
|
+
*
|
|
53
|
+
* Every candidate is tried, and the callback is accepted if one matches.
|
|
54
|
+
* Stopping at the first value carrying the cookie's name instead makes a
|
|
55
|
+
* planted cookie a permanent, unauthenticated denial of login: RFC 6265
|
|
56
|
+
* section 5.4 orders the `Cookie` header by path length then creation time,
|
|
57
|
+
* so an attacker with content control on a sibling subdomain sets a
|
|
58
|
+
* same-named cookie once and every subsequent callback for that victim reads
|
|
59
|
+
* theirs, fails the binding check, and burns the state on the way out. The
|
|
60
|
+
* victim cannot recover by retrying.
|
|
61
|
+
*
|
|
62
|
+
* Accepting any match gives an attacker nothing: they would have to present
|
|
63
|
+
* the victim's own binding value, which is the property being checked. And
|
|
64
|
+
* the candidate list is deliberately uncapped — a cap does not bound an
|
|
65
|
+
* attack, it *is* one, reinstating that denial above its own threshold
|
|
66
|
+
* because the planted cookies are the ones that sort first. The work is
|
|
67
|
+
* already bounded by Node's 16 KB header limit.
|
|
68
|
+
*
|
|
69
|
+
* The reduce does not short-circuit, so the work is a function of how many
|
|
70
|
+
* values were presented and not of which one matched.
|
|
71
|
+
*/
|
|
72
|
+
static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Starts a flow: an OAuth2 `state` for the provider, and a binding value for
|
|
75
|
+
* the client that asked for it.
|
|
76
|
+
*
|
|
77
|
+
* `state` on its own is replay-window limiting, not the CSRF binding it
|
|
78
|
+
* exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
|
|
79
|
+
* state issued to any visitor validated for any callback, so an attacker
|
|
80
|
+
* could harvest their own state and code, deliver them to a victim over a
|
|
81
|
+
* plain link, and log the victim into the attacker's account. The binding
|
|
82
|
+
* value is the thing the victim's browser carries and the attacker's does
|
|
83
|
+
* not (#36).
|
|
84
|
+
*/
|
|
85
|
+
getAuthorizationUrl(providerName: string): IssuedState;
|
|
86
|
+
/**
|
|
87
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
88
|
+
*
|
|
89
|
+
* Used by the login route when the binding cookie cannot be set: a state the
|
|
90
|
+
* client cannot be bound to is exactly the defect this mechanism exists to
|
|
91
|
+
* prevent, so it must not outlive the request that failed to bind it.
|
|
92
|
+
*/
|
|
93
|
+
discardState(stateToken: string): void;
|
|
94
|
+
/**
|
|
95
|
+
* Validates and consumes a pending state, then completes the flow.
|
|
96
|
+
*
|
|
97
|
+
* `bindingValues` is every value the client presented under the binding
|
|
98
|
+
* cookie's name — see `anyCandidateMatches`.
|
|
99
|
+
*/
|
|
100
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<import("./session-manager.js").SessionResult>;
|
|
101
|
+
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
102
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
19
103
|
getSession(sessionId: string): unknown;
|
|
20
104
|
logout(sessionId: string): void;
|
|
21
105
|
}
|
package/dist/main.js
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';
|
|
@@ -7,10 +8,15 @@ import TokenManager from './token-manager.js';
|
|
|
7
8
|
import SessionManager from './session-manager.js';
|
|
8
9
|
import AuthRequest from './auth-request.js';
|
|
9
10
|
setup(['authenticate']);
|
|
11
|
+
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
12
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
13
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
14
|
+
export const BINDING_VALUE_BYTES = 32;
|
|
10
15
|
export default class OAuth {
|
|
11
16
|
static instance;
|
|
12
17
|
providers = new Map();
|
|
13
18
|
pendingStates = new Map();
|
|
19
|
+
stateTtl = STATE_TTL_MS;
|
|
14
20
|
sessionManager;
|
|
15
21
|
frontendCallbackUrl;
|
|
16
22
|
constructor() {
|
|
@@ -45,24 +51,112 @@ export default class OAuth {
|
|
|
45
51
|
throw new Error(`OAuth provider "${name}" is not configured`);
|
|
46
52
|
return provider;
|
|
47
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* SHA-256 of a binding value, hex encoded.
|
|
56
|
+
*
|
|
57
|
+
* The pending record stores the digest so that read access to the map does
|
|
58
|
+
* not hand over the value a callback must present.
|
|
59
|
+
*/
|
|
60
|
+
static hash(value) {
|
|
61
|
+
return createHash('sha256').update(value).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
64
|
+
static digestsMatch(a, b) {
|
|
65
|
+
if (a.length !== b.length)
|
|
66
|
+
return false;
|
|
67
|
+
let difference = 0;
|
|
68
|
+
for (let index = 0; index < a.length; index++) {
|
|
69
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
70
|
+
}
|
|
71
|
+
return difference === 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether *any* presented value is the binding value for this record.
|
|
75
|
+
*
|
|
76
|
+
* Every candidate is tried, and the callback is accepted if one matches.
|
|
77
|
+
* Stopping at the first value carrying the cookie's name instead makes a
|
|
78
|
+
* planted cookie a permanent, unauthenticated denial of login: RFC 6265
|
|
79
|
+
* section 5.4 orders the `Cookie` header by path length then creation time,
|
|
80
|
+
* so an attacker with content control on a sibling subdomain sets a
|
|
81
|
+
* same-named cookie once and every subsequent callback for that victim reads
|
|
82
|
+
* theirs, fails the binding check, and burns the state on the way out. The
|
|
83
|
+
* victim cannot recover by retrying.
|
|
84
|
+
*
|
|
85
|
+
* Accepting any match gives an attacker nothing: they would have to present
|
|
86
|
+
* the victim's own binding value, which is the property being checked. And
|
|
87
|
+
* the candidate list is deliberately uncapped — a cap does not bound an
|
|
88
|
+
* attack, it *is* one, reinstating that denial above its own threshold
|
|
89
|
+
* because the planted cookies are the ones that sort first. The work is
|
|
90
|
+
* already bounded by Node's 16 KB header limit.
|
|
91
|
+
*
|
|
92
|
+
* The reduce does not short-circuit, so the work is a function of how many
|
|
93
|
+
* values were presented and not of which one matched.
|
|
94
|
+
*/
|
|
95
|
+
static anyCandidateMatches(candidates, bindingHash) {
|
|
96
|
+
return candidates.reduce((matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched, false);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Starts a flow: an OAuth2 `state` for the provider, and a binding value for
|
|
100
|
+
* the client that asked for it.
|
|
101
|
+
*
|
|
102
|
+
* `state` on its own is replay-window limiting, not the CSRF binding it
|
|
103
|
+
* exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
|
|
104
|
+
* state issued to any visitor validated for any callback, so an attacker
|
|
105
|
+
* could harvest their own state and code, deliver them to a victim over a
|
|
106
|
+
* plain link, and log the victim into the attacker's account. The binding
|
|
107
|
+
* value is the thing the victim's browser carries and the attacker's does
|
|
108
|
+
* not (#36).
|
|
109
|
+
*/
|
|
48
110
|
getAuthorizationUrl(providerName) {
|
|
49
111
|
const { flow } = this.getProvider(providerName);
|
|
50
|
-
const stateToken =
|
|
51
|
-
|
|
52
|
-
|
|
112
|
+
const stateToken = randomUUID();
|
|
113
|
+
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
114
|
+
this.pendingStates.set(stateToken, {
|
|
115
|
+
bindingHash: OAuth.hash(bindingValue),
|
|
116
|
+
createdAt: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
|
|
53
119
|
}
|
|
54
|
-
|
|
55
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
122
|
+
*
|
|
123
|
+
* Used by the login route when the binding cookie cannot be set: a state the
|
|
124
|
+
* client cannot be bound to is exactly the defect this mechanism exists to
|
|
125
|
+
* prevent, so it must not outlive the request that failed to bind it.
|
|
126
|
+
*/
|
|
127
|
+
discardState(stateToken) {
|
|
128
|
+
this.pendingStates.delete(stateToken);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Validates and consumes a pending state, then completes the flow.
|
|
132
|
+
*
|
|
133
|
+
* `bindingValues` is every value the client presented under the binding
|
|
134
|
+
* cookie's name — see `anyCandidateMatches`.
|
|
135
|
+
*/
|
|
136
|
+
async handleCallback(providerName, code, stateToken, bindingValues) {
|
|
137
|
+
const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
|
|
138
|
+
if (!record)
|
|
56
139
|
throw new Error('Invalid or missing state token');
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
140
|
+
// Consumed on recognition, before the TTL and binding checks, so every
|
|
141
|
+
// state gets exactly one attempt whatever the outcome. Checking the
|
|
142
|
+
// binding first would leave the record in place on a mismatch and turn
|
|
143
|
+
// this endpoint into a repeatable, unauthenticated oracle against the
|
|
144
|
+
// binding value for the state's full lifetime.
|
|
61
145
|
this.pendingStates.delete(stateToken);
|
|
62
|
-
|
|
63
|
-
if (Date.now() - stateCreatedAt > TEN_MINUTES) {
|
|
146
|
+
if (Date.now() - record.createdAt > this.stateTtl) {
|
|
64
147
|
throw new Error('State token has expired');
|
|
65
148
|
}
|
|
149
|
+
// No "absent means skip". An empty candidate list is a rejection, which is
|
|
150
|
+
// what makes an attacker-delivered link fail for a victim who never
|
|
151
|
+
// started the flow and therefore holds no binding cookie.
|
|
152
|
+
const candidates = bindingValues.filter(value => value.length > 0);
|
|
153
|
+
if (candidates.length === 0)
|
|
154
|
+
throw new Error('Missing state binding value');
|
|
155
|
+
if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
|
|
156
|
+
throw new Error('State token is not bound to this client');
|
|
157
|
+
}
|
|
158
|
+
// Everything below burns a live authorization code, so the binding is
|
|
159
|
+
// settled before `exchangeCode` is ever reached.
|
|
66
160
|
const { flow, tokenManager } = this.getProvider(providerName);
|
|
67
161
|
const tokens = await tokenManager.getTokens(code);
|
|
68
162
|
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
@@ -70,6 +164,10 @@ export default class OAuth {
|
|
|
70
164
|
await emit('authenticate', user);
|
|
71
165
|
return this.sessionManager.create(user, tokens);
|
|
72
166
|
}
|
|
167
|
+
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
168
|
+
redirectUriFor(providerName) {
|
|
169
|
+
return this.providers.get(providerName)?.flow.redirectUri;
|
|
170
|
+
}
|
|
73
171
|
getSession(sessionId) {
|
|
74
172
|
return this.sessionManager.validate(sessionId);
|
|
75
173
|
}
|
package/package.json
CHANGED
package/src/auth-request.ts
CHANGED
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
|
|
6
|
+
*
|
|
7
|
+
* The attributes below are load-bearing, not cosmetic:
|
|
8
|
+
*
|
|
9
|
+
* - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
|
|
10
|
+
* initiated by the provider. `Strict` withholds the cookie on exactly that
|
|
11
|
+
* request, breaking 100% of logins while passing every CSRF test; `None`
|
|
12
|
+
* requires `Secure` and widens exposure for no benefit.
|
|
13
|
+
* - `Path=/` — routing is case-insensitive today
|
|
14
|
+
* (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
|
|
15
|
+
* RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
|
|
16
|
+
* `/auth` silently drops the cookie on a case-varied callback and breaks
|
|
17
|
+
* login.
|
|
18
|
+
* - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_COOKIE_NAME = 'oauth_state';
|
|
21
|
+
const STATE_COOKIE_PATH = '/';
|
|
22
|
+
const STATE_COOKIE_SAME_SITE = 'lax';
|
|
23
|
+
|
|
24
|
+
interface AuthorizationRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
stateToken: string;
|
|
27
|
+
bindingValue: string;
|
|
28
|
+
}
|
|
2
29
|
|
|
3
30
|
interface OAuthInstance {
|
|
4
31
|
frontendCallbackUrl?: string;
|
|
32
|
+
stateTtl: number;
|
|
5
33
|
getSession(sessionId: string): unknown;
|
|
6
|
-
getAuthorizationUrl(providerName: string):
|
|
7
|
-
|
|
34
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
35
|
+
discardState(stateToken: string): void;
|
|
36
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
37
|
+
handleCallback(
|
|
38
|
+
providerName: string,
|
|
39
|
+
code: string,
|
|
40
|
+
stateToken: string,
|
|
41
|
+
bindingValues: readonly string[],
|
|
42
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
8
43
|
logout(sessionId: string): void;
|
|
9
44
|
}
|
|
10
45
|
|
|
46
|
+
export interface CookieOptions {
|
|
47
|
+
httpOnly: boolean;
|
|
48
|
+
sameSite: string;
|
|
49
|
+
path: string;
|
|
50
|
+
secure: boolean;
|
|
51
|
+
maxAge?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The response object express hangs off the request.
|
|
56
|
+
*
|
|
57
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
|
|
58
|
+
* is unreachable once `state.redirect` is set (`request.ts` returns on the
|
|
59
|
+
* redirect first), so setting a cookie means reaching for `req.res`.
|
|
60
|
+
*
|
|
61
|
+
* This is a deliberate, sanctioned interim reach-around, not an accident:
|
|
62
|
+
* `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
|
|
63
|
+
* first-class header/cookie affordance to migrate onto, and it is sequenced
|
|
64
|
+
* after this fix. `setBindingCookie` fails closed if the affordance is not
|
|
65
|
+
* there, which is what contains the dependency.
|
|
66
|
+
*/
|
|
67
|
+
interface ResponseLike {
|
|
68
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
69
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
11
72
|
interface RouteRequest {
|
|
12
73
|
headers: Record<string, string | undefined>;
|
|
13
74
|
params: Record<string, string>;
|
|
14
75
|
query: Record<string, string>;
|
|
76
|
+
res?: ResponseLike;
|
|
15
77
|
}
|
|
16
78
|
|
|
17
79
|
interface RouteState {
|
|
@@ -41,12 +103,22 @@ export default class AuthRequest extends Request {
|
|
|
41
103
|
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
42
104
|
const { provider: providerName } = req.params;
|
|
43
105
|
|
|
106
|
+
let authorization: AuthorizationRequest;
|
|
44
107
|
try {
|
|
45
|
-
|
|
46
|
-
state.redirect = url;
|
|
108
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
47
109
|
} catch {
|
|
48
110
|
return 404;
|
|
49
111
|
}
|
|
112
|
+
|
|
113
|
+
// Fail closed. A state we cannot bind to this client is exactly the
|
|
114
|
+
// defect this mechanism exists to prevent, so it is withdrawn rather
|
|
115
|
+
// than issued unbindable.
|
|
116
|
+
if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
|
|
117
|
+
this.oauth.discardState(authorization.stateToken);
|
|
118
|
+
return 500;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
state.redirect = authorization.url;
|
|
50
122
|
},
|
|
51
123
|
|
|
52
124
|
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
@@ -64,7 +136,23 @@ export default class AuthRequest extends Request {
|
|
|
64
136
|
if (!code) return 400;
|
|
65
137
|
|
|
66
138
|
try {
|
|
67
|
-
const session = await this.oauth.handleCallback(
|
|
139
|
+
const session = await this.oauth.handleCallback(
|
|
140
|
+
providerName,
|
|
141
|
+
code,
|
|
142
|
+
stateToken,
|
|
143
|
+
this.readBindingCookies(req),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Cleared only here, on the success path, which is the only path that
|
|
147
|
+
// is certain to have consumed a state belonging to *this* client.
|
|
148
|
+
//
|
|
149
|
+
// Clearing on failure instead looks harmless and is not: `code` is
|
|
150
|
+
// attacker-supplied and unvalidated, so a bare `?code=1` — no
|
|
151
|
+
// knowledge of anyone's state — would delete the binding cookie of a
|
|
152
|
+
// client still sitting on the provider's consent screen, leaving
|
|
153
|
+
// their pending state untouched so nothing is detectable
|
|
154
|
+
// server-side, and their real callback then fails.
|
|
155
|
+
this.clearBindingCookie(req, providerName);
|
|
68
156
|
|
|
69
157
|
if (this.oauth.frontendCallbackUrl) {
|
|
70
158
|
const params = new URLSearchParams({
|
|
@@ -91,4 +179,93 @@ export default class AuthRequest extends Request {
|
|
|
91
179
|
},
|
|
92
180
|
}
|
|
93
181
|
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
185
|
+
*
|
|
186
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
187
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
188
|
+
* a round trip to.
|
|
189
|
+
*
|
|
190
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
191
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
192
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
193
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
194
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
195
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
196
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
197
|
+
*
|
|
198
|
+
* An unparseable or absent redirect URI fails secure.
|
|
199
|
+
*/
|
|
200
|
+
isSecureContext(providerName: string): boolean {
|
|
201
|
+
const redirectUri = this.oauth.redirectUriFor(providerName);
|
|
202
|
+
if (!redirectUri) return true;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return new URL(redirectUri).protocol !== 'http:';
|
|
206
|
+
} catch {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
|
|
212
|
+
return {
|
|
213
|
+
httpOnly: true,
|
|
214
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
215
|
+
path: STATE_COOKIE_PATH,
|
|
216
|
+
secure: this.isSecureContext(providerName),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
|
|
221
|
+
const { res } = req;
|
|
222
|
+
|
|
223
|
+
if (typeof res?.cookie !== 'function') {
|
|
224
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
229
|
+
...this.cookieOptions(providerName),
|
|
230
|
+
maxAge: this.oauth.stateTtl,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every value the client presented under the binding cookie's name.
|
|
238
|
+
*
|
|
239
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
240
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
241
|
+
* for any victim they can plant a same-named cookie on.
|
|
242
|
+
*/
|
|
243
|
+
readBindingCookies(req: RouteRequest): string[] {
|
|
244
|
+
const header = req.headers.cookie;
|
|
245
|
+
if (!header) return [];
|
|
246
|
+
|
|
247
|
+
const values: string[] = [];
|
|
248
|
+
|
|
249
|
+
for (const part of header.split(';')) {
|
|
250
|
+
const separator = part.indexOf('=');
|
|
251
|
+
if (separator === -1) continue;
|
|
252
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
253
|
+
|
|
254
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
255
|
+
// `encodeURIComponent` never escapes, so decoding buys nothing — and
|
|
256
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
257
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
258
|
+
// callback into a 500.
|
|
259
|
+
values.push(part.slice(separator + 1).trim());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return values;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
clearBindingCookie(req: RouteRequest, providerName: string): void {
|
|
266
|
+
const { res } = req;
|
|
267
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
268
|
+
|
|
269
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
|
|
270
|
+
}
|
|
94
271
|
}
|
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';
|
|
@@ -10,11 +11,38 @@ import type OAuthFlow from './oauth-flow.js';
|
|
|
10
11
|
|
|
11
12
|
setup(['authenticate']);
|
|
12
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
|
+
|
|
13
20
|
interface ProviderEntry {
|
|
14
21
|
flow: OAuthFlow;
|
|
15
22
|
tokenManager: TokenManager;
|
|
16
23
|
}
|
|
17
24
|
|
|
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;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IssuedState {
|
|
38
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
39
|
+
url: string;
|
|
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. */
|
|
43
|
+
bindingValue: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
interface ProviderConfig {
|
|
19
47
|
module?: string;
|
|
20
48
|
[key: string]: unknown;
|
|
@@ -24,7 +52,8 @@ export default class OAuth {
|
|
|
24
52
|
static instance: OAuth | null;
|
|
25
53
|
|
|
26
54
|
providers = new Map<string, ProviderEntry>();
|
|
27
|
-
pendingStates = new Map<string,
|
|
55
|
+
pendingStates = new Map<string, PendingState>();
|
|
56
|
+
stateTtl = STATE_TTL_MS;
|
|
28
57
|
sessionManager!: SessionManager;
|
|
29
58
|
frontendCallbackUrl?: string;
|
|
30
59
|
|
|
@@ -66,27 +95,126 @@ export default class OAuth {
|
|
|
66
95
|
return provider;
|
|
67
96
|
}
|
|
68
97
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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');
|
|
74
106
|
}
|
|
75
107
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
79
115
|
}
|
|
80
116
|
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
162
|
+
const { flow } = this.getProvider(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
|
+
});
|
|
170
|
+
|
|
171
|
+
return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
176
|
+
*
|
|
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.
|
|
180
|
+
*/
|
|
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.
|
|
83
200
|
this.pendingStates.delete(stateToken);
|
|
84
201
|
|
|
85
|
-
|
|
86
|
-
if (Date.now() - stateCreatedAt > TEN_MINUTES) {
|
|
202
|
+
if (Date.now() - record.createdAt > this.stateTtl) {
|
|
87
203
|
throw new Error('State token has expired');
|
|
88
204
|
}
|
|
89
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.
|
|
90
218
|
const { flow, tokenManager } = this.getProvider(providerName);
|
|
91
219
|
const tokens = await tokenManager.getTokens(code);
|
|
92
220
|
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
@@ -95,6 +223,11 @@ export default class OAuth {
|
|
|
95
223
|
return this.sessionManager.create(user, tokens);
|
|
96
224
|
}
|
|
97
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
|
+
|
|
98
231
|
getSession(sessionId: string) {
|
|
99
232
|
return this.sessionManager.validate(sessionId);
|
|
100
233
|
}
|
package/src/types/node.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
1
|
declare module 'node:crypto' {
|
|
2
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
|
+
|
|
12
|
+
interface Hash {
|
|
13
|
+
update(data: string): Hash;
|
|
14
|
+
digest(encoding: string): string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function randomBytes(size: number): BinaryLike;
|
|
18
|
+
export function createHash(algorithm: string): Hash;
|
|
3
19
|
}
|
package/src/types/stonyx.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ declare module 'stonyx/config' {
|
|
|
18
18
|
declare module 'stonyx/log' {
|
|
19
19
|
interface Log {
|
|
20
20
|
oauth(message: string): void;
|
|
21
|
+
error(message: string): void;
|
|
21
22
|
defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
|
|
22
23
|
[key: string]: unknown;
|
|
23
24
|
}
|