@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/README.md
CHANGED
|
@@ -11,7 +11,7 @@ OAuth2 authentication module for the Stonyx framework. Provides a generic OAuth2
|
|
|
11
11
|
Add as a devDependency to your Stonyx project:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
14
|
+
npm install @stonyx/oauth
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires `@stonyx/rest-server` as a peer dependency.
|
|
@@ -55,167 +55,10 @@ The module self-registers the following routes on the rest server:
|
|
|
55
55
|
| Method | Route | Description |
|
|
56
56
|
|--------|-------|-------------|
|
|
57
57
|
| `GET` | `/auth` | Validate session — send `session-id` header, returns user or 401 |
|
|
58
|
-
| `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page
|
|
59
|
-
| `GET` | `/auth/callback/:provider` | OAuth2 callback —
|
|
58
|
+
| `GET` | `/auth/login/:provider` | Redirects to provider's OAuth2 authorization page |
|
|
59
|
+
| `GET` | `/auth/callback/:provider` | OAuth2 callback — exchanges code for tokens, creates session |
|
|
60
60
|
| `GET` | `/auth/logout` | Destroys session (send `session-id` header) |
|
|
61
61
|
|
|
62
|
-
### Starting the flow
|
|
63
|
-
|
|
64
|
-
Send the browser to `/auth/login/:provider` as a **top-level navigation**:
|
|
65
|
-
|
|
66
|
-
```javascript
|
|
67
|
-
window.location.href = 'https://api.example.com/auth/login/discord';
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Do not start the flow with `fetch()` or `XMLHttpRequest`. The login response
|
|
71
|
-
sets the state binding cookie described below, and the browser must be holding
|
|
72
|
-
that cookie when the provider redirects it back to `/auth/callback/:provider`.
|
|
73
|
-
|
|
74
|
-
## State Binding (CSRF Protection)
|
|
75
|
-
|
|
76
|
-
The OAuth2 `state` parameter only protects against login CSRF if it is bound to
|
|
77
|
-
the client that started the flow. This module binds it with a cookie.
|
|
78
|
-
|
|
79
|
-
On `GET /auth/login/:provider` the module issues a random 32-byte binding value,
|
|
80
|
-
stores only a SHA-256 digest of it server-side alongside the provider name and
|
|
81
|
-
issue time, and sends the plaintext to the client as a cookie:
|
|
82
|
-
|
|
83
|
-
| Attribute | Value | Why |
|
|
84
|
-
|-----------|-------|-----|
|
|
85
|
-
| Name | `stonyx_oauth_state` | |
|
|
86
|
-
| `HttpOnly` | set | script must not be able to read or forge the binding value |
|
|
87
|
-
| `SameSite` | `Lax` | **required.** The callback is a cross-site, top-level `GET` navigation from the provider. `SameSite=Strict` withholds the cookie on exactly that request and breaks login outright; `SameSite=None` requires `Secure` and widens exposure for no benefit |
|
|
88
|
-
| `Path` | `/auth` | the cookie is only ever read by the callback route |
|
|
89
|
-
| `Secure` | set on every host except loopback | deriving it from `req.secure` would omit it in the standard production topology: behind a TLS-terminating proxy Express reports the request as plaintext unless `trust proxy` is enabled, and `@stonyx/rest-server` leaves that off by default. A non-loopback plaintext deployment therefore cannot store this cookie — that failure is loud and deliberate, in preference to a silently insecure production cookie. The exemption rule is exact; see below |
|
|
90
|
-
| `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
|
|
91
|
-
|
|
92
|
-
#### Which hosts are exempt from `Secure`
|
|
93
|
-
|
|
94
|
-
The exemption is decided by parsing the `Host` header and testing the result for
|
|
95
|
-
membership. It is never a prefix or suffix match on the raw header value,
|
|
96
|
-
because `Host` is attacker-controllable from any non-browser client.
|
|
97
|
-
|
|
98
|
-
Exempt, and nothing else:
|
|
99
|
-
|
|
100
|
-
- `localhost` (case-insensitive)
|
|
101
|
-
- any address in `127.0.0.0/8` — a dotted quad of **four** decimal octets whose
|
|
102
|
-
first is `127`, so `127.0.0.2` is exempt and `127.evil.com` is not. The
|
|
103
|
-
resolver shorthands `127.1` and `127.0.0` are *not* exempt: accepting a
|
|
104
|
-
variable number of octets is how a membership test decays back into a prefix
|
|
105
|
-
match. Use the full form
|
|
106
|
-
- `::1` (and its expanded form), and the IPv4-mapped loopback spellings
|
|
107
|
-
`::ffff:127.0.0.1` and `::ffff:7f00:1`
|
|
108
|
-
- the wildcard bind addresses `0.0.0.0` and `::`
|
|
109
|
-
|
|
110
|
-
Everything else gets `Secure`, including:
|
|
111
|
-
|
|
112
|
-
- **`*.localhost`.** A `.localhost` suffix was exempt in an earlier draft of
|
|
113
|
-
this module and is not any more: a split-horizon vhost under that zone would
|
|
114
|
-
have shipped the binding value in cleartext. Develop against `localhost` or
|
|
115
|
-
`127.0.0.1`, which reach the same server.
|
|
116
|
-
- any `Host` that is not a well-formed `host[:port]` — a non-numeric port, a
|
|
117
|
-
userinfo segment (`localhost:80@evil.com`), a comma-joined multi-value, or a
|
|
118
|
-
character a registered name may not contain
|
|
119
|
-
- a request carrying **more than one `Host` header**. Node collapses repeats
|
|
120
|
-
into the first value, so an upstream component that prepends rather than
|
|
121
|
-
replaces a `Host:` line could otherwise downgrade the cookie on a response to
|
|
122
|
-
someone else. RFC 9112 section 3.2 makes such a request invalid; this module
|
|
123
|
-
treats it as unattributable.
|
|
124
|
-
- a request carrying **no `Host` header** at all
|
|
125
|
-
|
|
126
|
-
`X-Forwarded-Host` is deliberately **not** consulted.
|
|
127
|
-
|
|
128
|
-
`GET /auth/callback/:provider` accepts the callback only when all of the
|
|
129
|
-
following hold, and mints no session otherwise:
|
|
130
|
-
|
|
131
|
-
- the `state` is one this server issued and has not already been used
|
|
132
|
-
- it was issued for **this** provider
|
|
133
|
-
- it was issued less than 10 minutes ago
|
|
134
|
-
- the request carries a binding cookie whose value hashes to the stored digest
|
|
135
|
-
|
|
136
|
-
A client can hold more than one cookie of that name — a sibling subdomain can
|
|
137
|
-
set one on the parent domain, and the browser sends every applicable cookie in
|
|
138
|
-
a single header. **Every** value carrying the name is tried, up to eight, and
|
|
139
|
-
the callback is accepted if any of them matches. Reading only the first would
|
|
140
|
-
let anyone who can plant a cookie on the victim's domain deny them login
|
|
141
|
-
permanently: RFC 6265 section 5.4 sorts a planted cookie ahead of the real one
|
|
142
|
-
on equal paths, and the state is burned on every recognised callback, so
|
|
143
|
-
retrying does not help. Trying all of them concedes nothing, because the
|
|
144
|
-
attacker would still have to present the victim's own binding value.
|
|
145
|
-
|
|
146
|
-
The state and the cookie are both single-use: the pending record is consumed on
|
|
147
|
-
any callback that presents a recognised `state` — successful or not — and that
|
|
148
|
-
same response clears the cookie, scoped to `Path=/auth` so the deletion actually
|
|
149
|
-
reaches the cookie that was set.
|
|
150
|
-
|
|
151
|
-
A callback that consumes **nothing** — an unrecognised or absent `state`, a
|
|
152
|
-
provider `error`, a missing `code` — leaves the cookie alone. That is not
|
|
153
|
-
tidiness: `/auth/callback/:provider?code=1` is a request any attacker can induce
|
|
154
|
-
as a top-level navigation while a victim is still at the provider's consent
|
|
155
|
-
screen, and clearing on it would delete the victim's binding cookie without
|
|
156
|
-
touching anything the server could later notice.
|
|
157
|
-
|
|
158
|
-
Two distinct failure modes surface on two different routes. They are unrelated,
|
|
159
|
-
and the route is the fastest way to tell them apart:
|
|
160
|
-
|
|
161
|
-
- **The cookie cannot be set at all.** `GET /auth/login/:provider` responds
|
|
162
|
-
`500` rather than issuing a state it cannot bind, and logs
|
|
163
|
-
`OAuth: unable to set the state binding cookie; login rejected`. This is a
|
|
164
|
-
framework-wiring condition — the response object the module reaches for is
|
|
165
|
-
not there — not a network or proxy one.
|
|
166
|
-
- **`Set-Cookie` is stripped in transit** by a reverse proxy or CDN.
|
|
167
|
-
`GET /auth/login/:provider` **succeeds and redirects normally**; the module
|
|
168
|
-
never learns the header was dropped. The failure surfaces one hop later, at
|
|
169
|
-
`GET /auth/callback/:provider`, as `?error=auth_failed` on
|
|
170
|
-
`frontendCallbackUrl` (or a bare `500` when it is unset), with
|
|
171
|
-
`OAuth: callback rejected — Missing state binding value` in the log. First
|
|
172
|
-
thing to check: does the login response reach the browser carrying
|
|
173
|
-
`Set-Cookie: stonyx_oauth_state`.
|
|
174
|
-
|
|
175
|
-
Every callback rejection is logged server-side with its reason
|
|
176
|
-
(`OAuth: callback rejected — ...`), which distinguishes an unknown state, a
|
|
177
|
-
wrong provider, an expired state, a missing binding value and a wrong binding
|
|
178
|
-
value. Those five strings are this module's own; nothing caller-controlled
|
|
179
|
-
appears in them. The client-facing `auth_failed` stays deliberately opaque.
|
|
180
|
-
|
|
181
|
-
A failure thrown **below** the state check — by `getProvider`, by a provider's
|
|
182
|
-
`exchangeCode`, `fetchUserInfo` or `normalizeUser`, by an `authenticate`
|
|
183
|
-
subscriber, or by session creation — logs the fixed line
|
|
184
|
-
`OAuth: callback failed after state validation` and **nothing from the error
|
|
185
|
-
itself**. Custom providers are consumer code, and a provider error that carries
|
|
186
|
-
request context would otherwise put a `clientSecret` or the caller-supplied
|
|
187
|
-
`code` into your logs verbatim. If you need that detail, log it inside your
|
|
188
|
-
provider, where you control what goes in it.
|
|
189
|
-
|
|
190
|
-
A failed callback **cannot be retried**: the pending record is consumed on any
|
|
191
|
-
callback presenting a recognised `state`, so refreshing the error page or going
|
|
192
|
-
back and forward produces a second `auth_failed`. The user must restart at
|
|
193
|
-
`GET /auth/login/:provider`. Only one login can be in flight per browser at a
|
|
194
|
-
time, for the same reason — the binding cookie has one fixed name, so starting
|
|
195
|
-
a second login overwrites the first flow's binding value and the earlier flow
|
|
196
|
-
will fail at its callback.
|
|
197
|
-
|
|
198
|
-
### Custom flow drivers
|
|
199
|
-
|
|
200
|
-
Consumers that drive the flow themselves instead of using the routes above must
|
|
201
|
-
carry the binding value between the two calls:
|
|
202
|
-
|
|
203
|
-
```javascript
|
|
204
|
-
const { url, bindingValue } = oauth.getAuthorizationUrl('discord');
|
|
205
|
-
// hand bindingValue to the client, then on the callback:
|
|
206
|
-
const session = await oauth.handleCallback('discord', code, state, [bindingValue]);
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
The fourth argument is an **array** — every value the client presented under the
|
|
210
|
-
binding cookie's name. A driver that holds exactly one value passes
|
|
211
|
-
`[bindingValue]`; one that holds none passes `[]`.
|
|
212
|
-
|
|
213
|
-
> **Changed in the release that fixes [#36](https://github.com/abofs/stonyx-oauth/issues/36):**
|
|
214
|
-
> `getAuthorizationUrl(provider)` returned a URL string and now returns
|
|
215
|
-
> `{ url, bindingValue }`; `handleCallback(provider, code, state)` takes a
|
|
216
|
-
> required fourth argument, the client's binding values, as an array.
|
|
217
|
-
> Applications using the self-registering `/auth` routes need no changes.
|
|
218
|
-
|
|
219
62
|
## Officially Supported Providers
|
|
220
63
|
|
|
221
64
|
### Discord
|
|
@@ -276,12 +119,6 @@ providers: {
|
|
|
276
119
|
## Session Management
|
|
277
120
|
|
|
278
121
|
Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
|
|
279
|
-
Pending OAuth states are held in-memory too, so a restart mid-login, or more
|
|
280
|
-
than one instance behind a load balancer, will reject the callback. Pending
|
|
281
|
-
records are removed when a callback consumes them, not swept on a timer — the
|
|
282
|
-
ten-minute age bound is only evaluated when a matching callback arrives, so an
|
|
283
|
-
abandoned flow's record persists until the process restarts. See
|
|
284
|
-
[#38](https://github.com/abofs/stonyx-oauth/issues/38).
|
|
285
122
|
|
|
286
123
|
Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
|
|
287
124
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -1,51 +1,18 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
interface AuthorizationRequest {
|
|
3
|
-
url: string;
|
|
4
|
-
bindingValue: string;
|
|
5
|
-
}
|
|
6
2
|
interface OAuthInstance {
|
|
7
3
|
frontendCallbackUrl?: string;
|
|
8
4
|
getSession(sessionId: string): unknown;
|
|
9
|
-
getAuthorizationUrl(providerName: string):
|
|
10
|
-
handleCallback(providerName: string, code: string, stateToken: string
|
|
5
|
+
getAuthorizationUrl(providerName: string): string;
|
|
6
|
+
handleCallback(providerName: string, code: string, stateToken: string): Promise<{
|
|
11
7
|
sessionId: string;
|
|
12
8
|
expiresAt: number;
|
|
13
9
|
}>;
|
|
14
10
|
logout(sessionId: string): void;
|
|
15
11
|
}
|
|
16
|
-
interface CookieOptions {
|
|
17
|
-
httpOnly: boolean;
|
|
18
|
-
sameSite: string;
|
|
19
|
-
path: string;
|
|
20
|
-
secure: boolean;
|
|
21
|
-
maxAge?: number;
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* The response object Express hangs off the request.
|
|
25
|
-
*
|
|
26
|
-
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state` has no
|
|
27
|
-
* affordance for response headers, so setting a cookie means reaching for
|
|
28
|
-
* `req.res`. This is a deliberate, temporary escape hatch — tracked by
|
|
29
|
-
* `abofs/stonyx-rest-server#45`, which adds a first-class header affordance to
|
|
30
|
-
* migrate onto.
|
|
31
|
-
*/
|
|
32
|
-
interface ResponseLike {
|
|
33
|
-
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
34
|
-
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
35
|
-
}
|
|
36
12
|
interface RouteRequest {
|
|
37
13
|
headers: Record<string, string | undefined>;
|
|
38
|
-
/**
|
|
39
|
-
* Node's flat `[name, value, name, value, ...]` header list, when the runtime
|
|
40
|
-
* supplies it. Read only to detect a *duplicate* `Host`: Node collapses
|
|
41
|
-
* repeats into the first value, so `req.headers.host` alone cannot tell an
|
|
42
|
-
* unambiguous origin from a smuggled one.
|
|
43
|
-
*/
|
|
44
|
-
rawHeaders?: string[];
|
|
45
14
|
params: Record<string, string>;
|
|
46
15
|
query: Record<string, string>;
|
|
47
|
-
secure?: boolean;
|
|
48
|
-
res?: ResponseLike;
|
|
49
16
|
}
|
|
50
17
|
interface RouteState {
|
|
51
18
|
redirect?: string;
|
|
@@ -56,92 +23,13 @@ export default class AuthRequest extends Request {
|
|
|
56
23
|
handlers: {
|
|
57
24
|
get: {
|
|
58
25
|
'/': ({ headers }: RouteRequest) => {};
|
|
59
|
-
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 |
|
|
26
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 | undefined;
|
|
60
27
|
'/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
|
|
61
28
|
sessionId: string;
|
|
62
29
|
expiresAt: number;
|
|
63
|
-
} |
|
|
30
|
+
} | 400 | 500 | undefined>;
|
|
64
31
|
'/logout': ({ headers }: RouteRequest) => void;
|
|
65
32
|
};
|
|
66
33
|
};
|
|
67
|
-
cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
|
|
68
|
-
/**
|
|
69
|
-
* Whether the binding cookie is issued with `Secure`.
|
|
70
|
-
*
|
|
71
|
-
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
72
|
-
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
73
|
-
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
74
|
-
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
75
|
-
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
76
|
-
* cookie would ship without `Secure` while the deployment looks correct.
|
|
77
|
-
*
|
|
78
|
-
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
79
|
-
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
80
|
-
* at the first login and is loud. The alternative fails silently, in
|
|
81
|
-
* production, on the one attribute protecting the value this whole mechanism
|
|
82
|
-
* is built around.
|
|
83
|
-
*
|
|
84
|
-
* The exemption is decided by *parsing* the `Host` header and testing the
|
|
85
|
-
* result for membership, never by matching a prefix or a suffix on the raw
|
|
86
|
-
* value — `Host` is attacker-controllable on any non-browser client, and a
|
|
87
|
-
* security predicate written as a substring match drifts. Every shape that
|
|
88
|
-
* cannot be parsed as a bare `host[:port]`, and every request with more than
|
|
89
|
-
* one `Host`, fails secure.
|
|
90
|
-
*/
|
|
91
|
-
isSecureContext(req: RouteRequest): boolean;
|
|
92
|
-
/**
|
|
93
|
-
* True when the request carried more than one `Host` header.
|
|
94
|
-
*
|
|
95
|
-
* Node keeps the first and discards the rest, so a component that *prepends*
|
|
96
|
-
* a `Host:` line — request smuggling, or a proxy that appends rather than
|
|
97
|
-
* replaces — can make `req.headers.host` read `localhost` on a request whose
|
|
98
|
-
* real origin is public. RFC 9112 section 3.2 makes such a request invalid;
|
|
99
|
-
* this treats it as unattributable and fails secure rather than trusting it.
|
|
100
|
-
*/
|
|
101
|
-
static hasAmbiguousHost(req: RouteRequest): boolean;
|
|
102
|
-
/**
|
|
103
|
-
* The hostname component of a `Host` header, lowercased, or `undefined` when
|
|
104
|
-
* the value is not a well-formed `host[:port]`.
|
|
105
|
-
*
|
|
106
|
-
* `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
|
|
107
|
-
* `localhost:80@evil.com` reduces to `localhost`. The port is therefore
|
|
108
|
-
* required to be decimal, and the hostname to contain only characters a
|
|
109
|
-
* registered name may contain.
|
|
110
|
-
*/
|
|
111
|
-
static parseHostname(host: string): string | undefined;
|
|
112
|
-
/**
|
|
113
|
-
* Whether a parsed hostname is a loopback development origin.
|
|
114
|
-
*
|
|
115
|
-
* Membership tests, never prefix or suffix tests. `startsWith('127.')`
|
|
116
|
-
* matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
|
|
117
|
-
* leading digit in a label), and `endsWith('.localhost')` exempted an entire
|
|
118
|
-
* suffix — so a `.localhost` split-horizon vhost shipped the binding value in
|
|
119
|
-
* cleartext. The `.localhost` exemption is withdrawn rather than tightened:
|
|
120
|
-
* the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
|
|
121
|
-
* never documented it, and a developer on `app.localhost` reaches the same
|
|
122
|
-
* server on `localhost` or `127.0.0.1`.
|
|
123
|
-
*/
|
|
124
|
-
static isLoopbackHost(hostname: string): boolean;
|
|
125
|
-
setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
|
|
126
|
-
/**
|
|
127
|
-
* Every value the client presented under the binding cookie's name.
|
|
128
|
-
*
|
|
129
|
-
* Not the first one. A browser sends every applicable cookie in a single
|
|
130
|
-
* header, and a sibling subdomain can set a same-named cookie on the parent
|
|
131
|
-
* domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
|
|
132
|
-
* returning on the first name match handed an attacker a permanent,
|
|
133
|
-
* unauthenticated denial of login for any victim they could plant a cookie
|
|
134
|
-
* on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
|
|
135
|
-
* is writing, not reading.
|
|
136
|
-
*
|
|
137
|
-
* Every value is returned, with no cap. A cap here does not bound an attack,
|
|
138
|
-
* it *is* one: truncating the list reinstates exactly the denial above its
|
|
139
|
-
* own threshold, because the planted cookies are the ones that sort first.
|
|
140
|
-
* The work is already bounded by Node's 16 KB header limit — at most 779
|
|
141
|
-
* hashable candidates, 0.32 ms to parse and hash all of them. See
|
|
142
|
-
* `constants.ts` for the measurement.
|
|
143
|
-
*/
|
|
144
|
-
readBindingCookies(req: RouteRequest): string[];
|
|
145
|
-
clearBindingCookie(req: RouteRequest): void;
|
|
146
34
|
}
|
|
147
35
|
export {};
|
package/dist/auth-request.js
CHANGED
|
@@ -1,52 +1,4 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
import log from 'stonyx/log';
|
|
3
|
-
import { StateRejection } from './state-store.js';
|
|
4
|
-
import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
|
|
5
|
-
/**
|
|
6
|
-
* Hosts treated as a development origin by exact match, and — together with
|
|
7
|
-
* `127.0.0.0/8` and the IPv4-mapped IPv6 spellings of it — the only ones exempt
|
|
8
|
-
* from `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
|
|
9
|
-
*
|
|
10
|
-
* `0.0.0.0` and `::` are the wildcard bind addresses a developer reaches a
|
|
11
|
-
* local server on; `127.0.0.1` is covered by the `127.0.0.0/8` test rather than
|
|
12
|
-
* listed here, so the two are not silently redundant.
|
|
13
|
-
*/
|
|
14
|
-
const LOOPBACK_HOSTS = new Set(['localhost', '::1', '0:0:0:0:0:0:0:1', '0.0.0.0', '::']);
|
|
15
|
-
/** `host` values whose port component is anything but a decimal port are rejected. */
|
|
16
|
-
const PORT_PATTERN = /^\d{1,5}$/;
|
|
17
|
-
/**
|
|
18
|
-
* The characters RFC 1123 permits in a registered hostname, plus `.`.
|
|
19
|
-
*
|
|
20
|
-
* Anything else — `@`, `,`, whitespace, `/` — means the value is not a bare
|
|
21
|
-
* hostname, and the caller fails secure rather than guessing. This is what
|
|
22
|
-
* rejects `localhost:80@evil.com` and a comma-joined multi-value `Host`.
|
|
23
|
-
*/
|
|
24
|
-
const HOSTNAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
25
|
-
/** A dotted-quad whose first octet is 127, i.e. real `127.0.0.0/8` membership. */
|
|
26
|
-
function isLoopbackIpv4(hostname) {
|
|
27
|
-
const octets = hostname.split('.');
|
|
28
|
-
if (octets.length !== 4)
|
|
29
|
-
return false;
|
|
30
|
-
if (!octets.every(octet => /^\d{1,3}$/.test(octet) && Number(octet) <= 255))
|
|
31
|
-
return false;
|
|
32
|
-
return Number(octets[0]) === 127;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* IPv4-mapped IPv6 loopback, in both spellings a dual-stack listener produces:
|
|
36
|
-
* `::ffff:127.0.0.1` and `::ffff:7f00:1`.
|
|
37
|
-
*/
|
|
38
|
-
function isLoopbackIpv6(hostname) {
|
|
39
|
-
const mapped = /^::ffff:(.+)$/.exec(hostname);
|
|
40
|
-
if (!mapped)
|
|
41
|
-
return false;
|
|
42
|
-
const rest = mapped[1];
|
|
43
|
-
if (isLoopbackIpv4(rest))
|
|
44
|
-
return true;
|
|
45
|
-
const hextets = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest);
|
|
46
|
-
if (!hextets)
|
|
47
|
-
return false;
|
|
48
|
-
return parseInt(hextets[1], 16) >>> 8 === 127;
|
|
49
|
-
}
|
|
50
2
|
export default class AuthRequest extends Request {
|
|
51
3
|
oauth;
|
|
52
4
|
constructor(oauth) {
|
|
@@ -66,23 +18,17 @@ export default class AuthRequest extends Request {
|
|
|
66
18
|
},
|
|
67
19
|
'/login/:provider': (req, state) => {
|
|
68
20
|
const { provider: providerName } = req.params;
|
|
69
|
-
let authorization;
|
|
70
21
|
try {
|
|
71
|
-
|
|
22
|
+
const url = this.oauth.getAuthorizationUrl(providerName);
|
|
23
|
+
state.redirect = url;
|
|
72
24
|
}
|
|
73
25
|
catch {
|
|
74
26
|
return 404;
|
|
75
27
|
}
|
|
76
|
-
// Fail closed: a state we cannot bind to this client is exactly the
|
|
77
|
-
// defect this mechanism exists to prevent, so never issue one.
|
|
78
|
-
if (!this.setBindingCookie(req, authorization.bindingValue))
|
|
79
|
-
return 500;
|
|
80
|
-
state.redirect = authorization.url;
|
|
81
28
|
},
|
|
82
29
|
'/callback/:provider': async (req, state) => {
|
|
83
30
|
const { provider: providerName } = req.params;
|
|
84
31
|
const { code, state: stateToken, error } = req.query;
|
|
85
|
-
const bindingValues = this.readBindingCookies(req);
|
|
86
32
|
if (error) {
|
|
87
33
|
if (this.oauth.frontendCallbackUrl) {
|
|
88
34
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
@@ -93,10 +39,7 @@ export default class AuthRequest extends Request {
|
|
|
93
39
|
if (!code)
|
|
94
40
|
return 400;
|
|
95
41
|
try {
|
|
96
|
-
const session = await this.oauth.handleCallback(providerName, code, stateToken
|
|
97
|
-
// The binding value is single-use and the state has now been
|
|
98
|
-
// consumed, so this is the end of that cookie's life.
|
|
99
|
-
this.clearBindingCookie(req);
|
|
42
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken);
|
|
100
43
|
if (this.oauth.frontendCallbackUrl) {
|
|
101
44
|
const params = new URLSearchParams({
|
|
102
45
|
sessionId: session.sessionId,
|
|
@@ -107,52 +50,7 @@ export default class AuthRequest extends Request {
|
|
|
107
50
|
}
|
|
108
51
|
return session;
|
|
109
52
|
}
|
|
110
|
-
catch
|
|
111
|
-
// Clear only when this request actually spent the cookie.
|
|
112
|
-
//
|
|
113
|
-
// Moving the clear below the `error` and `!code` returns was not
|
|
114
|
-
// enough: it still ran unconditionally for any request carrying a
|
|
115
|
-
// `code`, and `code` is attacker-supplied and unvalidated. So
|
|
116
|
-
// `?code=1` — one query parameter, no knowledge of the victim's state
|
|
117
|
-
// — deleted the binding cookie of a client still at the provider's
|
|
118
|
-
// consent screen, leaving their pending state untouched so nothing
|
|
119
|
-
// was detectable server-side, and their real callback then failed.
|
|
120
|
-
//
|
|
121
|
-
// `StateRejection.consumed` is the only thing that distinguishes
|
|
122
|
-
// "nothing of this client's was touched" from "one attempt was
|
|
123
|
-
// spent". Anything that is not a `StateRejection` was thrown below
|
|
124
|
-
// the state check, which means the record was already burned.
|
|
125
|
-
if (!(rejection instanceof StateRejection) || rejection.consumed) {
|
|
126
|
-
this.clearBindingCookie(req);
|
|
127
|
-
}
|
|
128
|
-
// `StateStore.consume` distinguishes five rejection reasons that
|
|
129
|
-
// otherwise collapse into one opaque outcome with no server-side
|
|
130
|
-
// signal at all. The client-facing `auth_failed` stays opaque; the
|
|
131
|
-
// server has no reason to be.
|
|
132
|
-
//
|
|
133
|
-
// Only a `StateRejection`'s message is logged, and those are the
|
|
134
|
-
// fixed strings in `STATE_REJECTION`. The `try` above spans far more
|
|
135
|
-
// than `consume` — `getProvider`, `TokenManager.getTokens` ->
|
|
136
|
-
// `flow.exchangeCode`, `flow.fetchUserInfo`, `flow.normalizeUser`,
|
|
137
|
-
// `emit('authenticate')`, `sessionManager.create` — and three of
|
|
138
|
-
// those are consumer-overridable through the documented
|
|
139
|
-
// `providers.<name>.module` extension point. A provider that puts
|
|
140
|
-
// request context in its error, which is ordinary practice, would
|
|
141
|
-
// otherwise land its `clientSecret` and the caller-supplied `code` in
|
|
142
|
-
// the log verbatim; `@stonyx/logs` appends content raw when
|
|
143
|
-
// `logToFile` is enabled, so an echoed `code` is also a CRLF
|
|
144
|
-
// log-forging primitive for an unauthenticated caller. Before this
|
|
145
|
-
// module logged anything, all of that was swallowed.
|
|
146
|
-
//
|
|
147
|
-
// Anything below the state check therefore gets a fixed
|
|
148
|
-
// discriminator, and the detail is left to whatever the provider
|
|
149
|
-
// itself logs.
|
|
150
|
-
if (rejection instanceof StateRejection) {
|
|
151
|
-
log.error(`OAuth: callback rejected — ${rejection.message}`);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
log.error('OAuth: callback failed after state validation');
|
|
155
|
-
}
|
|
53
|
+
catch {
|
|
156
54
|
if (this.oauth.frontendCallbackUrl) {
|
|
157
55
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
158
56
|
return;
|
|
@@ -167,176 +65,4 @@ export default class AuthRequest extends Request {
|
|
|
167
65
|
},
|
|
168
66
|
}
|
|
169
67
|
};
|
|
170
|
-
cookieOptions(req) {
|
|
171
|
-
return {
|
|
172
|
-
httpOnly: true,
|
|
173
|
-
// Load-bearing: the callback is a cross-site top-level GET navigation
|
|
174
|
-
// from the provider. `Strict` withholds the cookie on exactly that
|
|
175
|
-
// request and breaks login outright.
|
|
176
|
-
sameSite: STATE_COOKIE_SAME_SITE,
|
|
177
|
-
path: STATE_COOKIE_PATH,
|
|
178
|
-
secure: this.isSecureContext(req),
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Whether the binding cookie is issued with `Secure`.
|
|
183
|
-
*
|
|
184
|
-
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
185
|
-
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
186
|
-
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
187
|
-
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
188
|
-
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
189
|
-
* cookie would ship without `Secure` while the deployment looks correct.
|
|
190
|
-
*
|
|
191
|
-
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
192
|
-
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
193
|
-
* at the first login and is loud. The alternative fails silently, in
|
|
194
|
-
* production, on the one attribute protecting the value this whole mechanism
|
|
195
|
-
* is built around.
|
|
196
|
-
*
|
|
197
|
-
* The exemption is decided by *parsing* the `Host` header and testing the
|
|
198
|
-
* result for membership, never by matching a prefix or a suffix on the raw
|
|
199
|
-
* value — `Host` is attacker-controllable on any non-browser client, and a
|
|
200
|
-
* security predicate written as a substring match drifts. Every shape that
|
|
201
|
-
* cannot be parsed as a bare `host[:port]`, and every request with more than
|
|
202
|
-
* one `Host`, fails secure.
|
|
203
|
-
*/
|
|
204
|
-
isSecureContext(req) {
|
|
205
|
-
if (req.secure === true)
|
|
206
|
-
return true;
|
|
207
|
-
if (AuthRequest.hasAmbiguousHost(req))
|
|
208
|
-
return true;
|
|
209
|
-
const host = req.headers.host;
|
|
210
|
-
if (!host)
|
|
211
|
-
return true;
|
|
212
|
-
const hostname = AuthRequest.parseHostname(host);
|
|
213
|
-
if (hostname === undefined)
|
|
214
|
-
return true;
|
|
215
|
-
return !AuthRequest.isLoopbackHost(hostname);
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* True when the request carried more than one `Host` header.
|
|
219
|
-
*
|
|
220
|
-
* Node keeps the first and discards the rest, so a component that *prepends*
|
|
221
|
-
* a `Host:` line — request smuggling, or a proxy that appends rather than
|
|
222
|
-
* replaces — can make `req.headers.host` read `localhost` on a request whose
|
|
223
|
-
* real origin is public. RFC 9112 section 3.2 makes such a request invalid;
|
|
224
|
-
* this treats it as unattributable and fails secure rather than trusting it.
|
|
225
|
-
*/
|
|
226
|
-
static hasAmbiguousHost(req) {
|
|
227
|
-
const raw = req.rawHeaders;
|
|
228
|
-
if (!Array.isArray(raw))
|
|
229
|
-
return false;
|
|
230
|
-
let seen = 0;
|
|
231
|
-
for (let index = 0; index < raw.length; index += 2) {
|
|
232
|
-
if (typeof raw[index] === 'string' && raw[index].toLowerCase() === 'host')
|
|
233
|
-
seen++;
|
|
234
|
-
}
|
|
235
|
-
return seen > 1;
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* The hostname component of a `Host` header, lowercased, or `undefined` when
|
|
239
|
-
* the value is not a well-formed `host[:port]`.
|
|
240
|
-
*
|
|
241
|
-
* `host.split(':')[0]` is not enough: it truncates at the *first* colon, so
|
|
242
|
-
* `localhost:80@evil.com` reduces to `localhost`. The port is therefore
|
|
243
|
-
* required to be decimal, and the hostname to contain only characters a
|
|
244
|
-
* registered name may contain.
|
|
245
|
-
*/
|
|
246
|
-
static parseHostname(host) {
|
|
247
|
-
if (host.startsWith('[')) {
|
|
248
|
-
const close = host.indexOf(']');
|
|
249
|
-
if (close === -1)
|
|
250
|
-
return undefined;
|
|
251
|
-
const port = host.slice(close + 1);
|
|
252
|
-
if (port !== '' && !(port.startsWith(':') && PORT_PATTERN.test(port.slice(1))))
|
|
253
|
-
return undefined;
|
|
254
|
-
const literal = host.slice(1, close);
|
|
255
|
-
if (!/^[0-9A-Fa-f:.]+$/.test(literal))
|
|
256
|
-
return undefined;
|
|
257
|
-
return literal.toLowerCase();
|
|
258
|
-
}
|
|
259
|
-
const colon = host.indexOf(':');
|
|
260
|
-
if (colon === -1)
|
|
261
|
-
return HOSTNAME_PATTERN.test(host) ? host.toLowerCase() : undefined;
|
|
262
|
-
if (!PORT_PATTERN.test(host.slice(colon + 1)))
|
|
263
|
-
return undefined;
|
|
264
|
-
const name = host.slice(0, colon);
|
|
265
|
-
return HOSTNAME_PATTERN.test(name) ? name.toLowerCase() : undefined;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Whether a parsed hostname is a loopback development origin.
|
|
269
|
-
*
|
|
270
|
-
* Membership tests, never prefix or suffix tests. `startsWith('127.')`
|
|
271
|
-
* matched `127.evil.com`, a perfectly registerable name (RFC 1123 permits a
|
|
272
|
-
* leading digit in a label), and `endsWith('.localhost')` exempted an entire
|
|
273
|
-
* suffix — so a `.localhost` split-horizon vhost shipped the binding value in
|
|
274
|
-
* cleartext. The `.localhost` exemption is withdrawn rather than tightened:
|
|
275
|
-
* the README documented `127.0.0.0/8`, `localhost`, `::1` and `0.0.0.0` and
|
|
276
|
-
* never documented it, and a developer on `app.localhost` reaches the same
|
|
277
|
-
* server on `localhost` or `127.0.0.1`.
|
|
278
|
-
*/
|
|
279
|
-
static isLoopbackHost(hostname) {
|
|
280
|
-
if (LOOPBACK_HOSTS.has(hostname))
|
|
281
|
-
return true;
|
|
282
|
-
if (isLoopbackIpv4(hostname))
|
|
283
|
-
return true;
|
|
284
|
-
return isLoopbackIpv6(hostname);
|
|
285
|
-
}
|
|
286
|
-
setBindingCookie(req, bindingValue) {
|
|
287
|
-
const { res } = req;
|
|
288
|
-
if (typeof res?.cookie !== 'function') {
|
|
289
|
-
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
293
|
-
...this.cookieOptions(req),
|
|
294
|
-
maxAge: STATE_TTL_MS,
|
|
295
|
-
});
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Every value the client presented under the binding cookie's name.
|
|
300
|
-
*
|
|
301
|
-
* Not the first one. A browser sends every applicable cookie in a single
|
|
302
|
-
* header, and a sibling subdomain can set a same-named cookie on the parent
|
|
303
|
-
* domain that RFC 6265 section 5.4 orders *ahead* of the real one — so
|
|
304
|
-
* returning on the first name match handed an attacker a permanent,
|
|
305
|
-
* unauthenticated denial of login for any victim they could plant a cookie
|
|
306
|
-
* on. `Secure`, `HttpOnly` and `SameSite` do not constrain that: the attacker
|
|
307
|
-
* is writing, not reading.
|
|
308
|
-
*
|
|
309
|
-
* Every value is returned, with no cap. A cap here does not bound an attack,
|
|
310
|
-
* it *is* one: truncating the list reinstates exactly the denial above its
|
|
311
|
-
* own threshold, because the planted cookies are the ones that sort first.
|
|
312
|
-
* The work is already bounded by Node's 16 KB header limit — at most 779
|
|
313
|
-
* hashable candidates, 0.32 ms to parse and hash all of them. See
|
|
314
|
-
* `constants.ts` for the measurement.
|
|
315
|
-
*/
|
|
316
|
-
readBindingCookies(req) {
|
|
317
|
-
const header = req.headers.cookie;
|
|
318
|
-
if (!header)
|
|
319
|
-
return [];
|
|
320
|
-
const values = [];
|
|
321
|
-
for (const part of header.split(';')) {
|
|
322
|
-
const separator = part.indexOf('=');
|
|
323
|
-
if (separator === -1)
|
|
324
|
-
continue;
|
|
325
|
-
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
|
|
326
|
-
continue;
|
|
327
|
-
// Not decoded. The binding value is base64url, whose alphabet
|
|
328
|
-
// `encodeURIComponent` never escapes, so a decode buys nothing — and
|
|
329
|
-
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
330
|
-
// unauthenticated caller can supply, turning the first line of the
|
|
331
|
-
// callback into a 500 with a stack trace.
|
|
332
|
-
values.push(part.slice(separator + 1).trim());
|
|
333
|
-
}
|
|
334
|
-
return values;
|
|
335
|
-
}
|
|
336
|
-
clearBindingCookie(req) {
|
|
337
|
-
const { res } = req;
|
|
338
|
-
if (typeof res?.clearCookie !== 'function')
|
|
339
|
-
return;
|
|
340
|
-
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
|
|
341
|
-
}
|
|
342
68
|
}
|