@stonyx/oauth 0.1.1-alpha.21 → 0.1.1-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- pnpm add @stonyx/oauth
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, and sets the state binding cookie |
59
- | `GET` | `/auth/callback/:provider` | OAuth2 callback — verifies the state binding, exchanges code for tokens, creates session |
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
@@ -273,15 +116,54 @@ providers: {
273
116
  }
274
117
  ```
275
118
 
119
+ ## Login CSRF protection — the `oauth_state` cookie
120
+
121
+ **Breaking, as of the fix for [#36](https://github.com/abofs/stonyx-oauth/issues/36): 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.
122
+
123
+ `GET /auth/login/:provider` issues a single-use `oauth_state` cookie 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.
124
+
125
+ 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.
126
+
127
+ ### Cookie attributes
128
+
129
+ | Attribute | Value | Why |
130
+ |-----------|-------|-----|
131
+ | Name | `oauth_state` | Single-use; issued at login, cleared on a successful callback |
132
+ | `HttpOnly` | always | Script must not be able to read or forge the binding value |
133
+ | `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 |
134
+ | `Path` | `/` | Routing is case-insensitive; RFC 6265 `Path` matching is not. A narrower path silently drops the cookie on a case-varied callback |
135
+ | `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` |
136
+ | `Max-Age` | 600 seconds | Matches the server-side state TTL |
137
+
138
+ If the runtime cannot set the cookie, `/auth/login/:provider` returns `500` and issues no state, rather than issuing one that cannot be bound.
139
+
140
+ ### Requirements for consumers
141
+
142
+ - **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.
143
+ - **Serve login and callback from the same origin.** A cookie set on the login origin is not sent to a different callback origin, and the login fails.
144
+ - An XHR-initiated login will not work: `@stonyx/rest-server` sets `origin: '*'` without `credentials: true`, so the browser will neither store nor send the cookie on a cross-origin XHR.
145
+
146
+ ### Migration from a cookie-less client
147
+
148
+ 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:
149
+
150
+ ```javascript
151
+ const login = await fetch(`${host}/auth/login/discord`, { redirect: 'manual' });
152
+ const cookie = login.headers.getSetCookie().map(header => header.split(';')[0]).join('; ');
153
+ const state = new URL(login.headers.get('location')).searchParams.get('state');
154
+
155
+ // ...provider redirects back with `code`...
156
+ await fetch(`${host}/auth/callback/discord?code=${code}&state=${state}`, {
157
+ redirect: 'manual',
158
+ headers: { cookie },
159
+ });
160
+ ```
161
+
162
+ 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.
163
+
276
164
  ## Session Management
277
165
 
278
166
  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
167
 
286
168
  Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
287
169
 
@@ -1,12 +1,16 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  interface AuthorizationRequest {
3
3
  url: string;
4
+ stateToken: string;
4
5
  bindingValue: string;
5
6
  }
6
7
  interface OAuthInstance {
7
8
  frontendCallbackUrl?: string;
9
+ stateTtl: number;
8
10
  getSession(sessionId: string): unknown;
9
11
  getAuthorizationUrl(providerName: string): AuthorizationRequest;
12
+ discardState(stateToken: string): void;
13
+ redirectUriFor(providerName: string): string | undefined;
10
14
  handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]): Promise<{
11
15
  sessionId: string;
12
16
  expiresAt: number;
@@ -21,13 +25,17 @@ interface CookieOptions {
21
25
  maxAge?: number;
22
26
  }
23
27
  /**
24
- * The response object Express hangs off the request.
28
+ * The response object express hangs off the request.
25
29
  *
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.
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.
31
39
  */
32
40
  interface ResponseLike {
33
41
  cookie(name: string, value: string, options: CookieOptions): unknown;
@@ -35,16 +43,8 @@ interface ResponseLike {
35
43
  }
36
44
  interface RouteRequest {
37
45
  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
46
  params: Record<string, string>;
46
47
  query: Record<string, string>;
47
- secure?: boolean;
48
48
  res?: ResponseLike;
49
49
  }
50
50
  interface RouteState {
@@ -64,84 +64,34 @@ export default class AuthRequest extends Request {
64
64
  '/logout': ({ headers }: RouteRequest) => void;
65
65
  };
66
66
  };
67
- cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
68
67
  /**
69
68
  * Whether the binding cookie is issued with `Secure`.
70
69
  *
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.
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.
77
73
  *
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.
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.
83
81
  *
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.
82
+ * An unparseable or absent redirect URI fails secure.
90
83
  */
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;
84
+ isSecureContext(providerName: string): boolean;
85
+ cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'>;
86
+ setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean;
126
87
  /**
127
88
  * Every value the client presented under the binding cookie's name.
128
89
  *
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.
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.
143
93
  */
144
94
  readBindingCookies(req: RouteRequest): string[];
145
- clearBindingCookie(req: RouteRequest): void;
95
+ clearBindingCookie(req: RouteRequest, providerName: string): void;
146
96
  }
147
97
  export {};