@stonyx/oauth 0.1.1-alpha.15 → 0.1.1-alpha.17
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 +98 -3
- package/dist/auth-request.d.ts +51 -4
- package/dist/auth-request.js +113 -4
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +16 -0
- package/dist/main.d.ts +22 -3
- package/dist/main.js +14 -16
- package/dist/state-store.d.ts +60 -0
- package/dist/state-store.js +81 -0
- package/package.json +6 -6
- package/src/auth-request.ts +167 -6
- package/src/constants.ts +19 -0
- package/src/main.ts +32 -18
- package/src/state-store.ts +108 -0
- package/src/types/node.d.ts +7 -0
- package/src/types/stonyx.d.ts +1 -0
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
|
+
pnpm add @stonyx/oauth
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires `@stonyx/rest-server` as a peer dependency.
|
|
@@ -55,10 +55,99 @@ 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 — exchanges code for tokens, creates session |
|
|
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 |
|
|
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 (`localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`) | 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 |
|
|
90
|
+
| `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
|
|
91
|
+
|
|
92
|
+
`GET /auth/callback/:provider` accepts the callback only when all of the
|
|
93
|
+
following hold, and mints no session otherwise:
|
|
94
|
+
|
|
95
|
+
- the `state` is one this server issued and has not already been used
|
|
96
|
+
- it was issued for **this** provider
|
|
97
|
+
- it was issued less than 10 minutes ago
|
|
98
|
+
- the request carries the binding cookie whose value hashes to the stored digest
|
|
99
|
+
|
|
100
|
+
The state and the cookie are both single-use: the pending record is consumed on
|
|
101
|
+
any callback that presents a recognised `state` — successful or not — and the
|
|
102
|
+
callback response clears the cookie.
|
|
103
|
+
|
|
104
|
+
Two distinct failure modes surface on two different routes. They are unrelated,
|
|
105
|
+
and the route is the fastest way to tell them apart:
|
|
106
|
+
|
|
107
|
+
- **The cookie cannot be set at all.** `GET /auth/login/:provider` responds
|
|
108
|
+
`500` rather than issuing a state it cannot bind, and logs
|
|
109
|
+
`OAuth: unable to set the state binding cookie; login rejected`. This is a
|
|
110
|
+
framework-wiring condition — the response object the module reaches for is
|
|
111
|
+
not there — not a network or proxy one.
|
|
112
|
+
- **`Set-Cookie` is stripped in transit** by a reverse proxy or CDN.
|
|
113
|
+
`GET /auth/login/:provider` **succeeds and redirects normally**; the module
|
|
114
|
+
never learns the header was dropped. The failure surfaces one hop later, at
|
|
115
|
+
`GET /auth/callback/:provider`, as `?error=auth_failed` on
|
|
116
|
+
`frontendCallbackUrl` (or a bare `500` when it is unset), with
|
|
117
|
+
`OAuth: callback rejected — Missing state binding value` in the log. First
|
|
118
|
+
thing to check: does the login response reach the browser carrying
|
|
119
|
+
`Set-Cookie: stonyx_oauth_state`.
|
|
120
|
+
|
|
121
|
+
Every callback rejection is logged server-side with its reason
|
|
122
|
+
(`OAuth: callback rejected — ...`), which distinguishes an unknown state, a
|
|
123
|
+
wrong provider, an expired state, a missing binding value and a wrong binding
|
|
124
|
+
value. The client-facing `auth_failed` stays deliberately opaque.
|
|
125
|
+
|
|
126
|
+
A failed callback **cannot be retried**: the pending record is consumed on any
|
|
127
|
+
callback presenting a recognised `state`, so refreshing the error page or going
|
|
128
|
+
back and forward produces a second `auth_failed`. The user must restart at
|
|
129
|
+
`GET /auth/login/:provider`. Only one login can be in flight per browser at a
|
|
130
|
+
time, for the same reason — the binding cookie has one fixed name, so starting
|
|
131
|
+
a second login overwrites the first flow's binding value and the earlier flow
|
|
132
|
+
will fail at its callback.
|
|
133
|
+
|
|
134
|
+
### Custom flow drivers
|
|
135
|
+
|
|
136
|
+
Consumers that drive the flow themselves instead of using the routes above must
|
|
137
|
+
carry the binding value between the two calls:
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
const { url, bindingValue } = oauth.getAuthorizationUrl('discord');
|
|
141
|
+
// hand bindingValue to the client, then on the callback:
|
|
142
|
+
const session = await oauth.handleCallback('discord', code, state, bindingValue);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> **Changed in the release that fixes [#36](https://github.com/abofs/stonyx-oauth/issues/36):**
|
|
146
|
+
> `getAuthorizationUrl(provider)` returned a URL string and now returns
|
|
147
|
+
> `{ url, bindingValue }`; `handleCallback(provider, code, state)` takes a
|
|
148
|
+
> fourth argument, the client's binding value. Applications using the
|
|
149
|
+
> self-registering `/auth` routes need no changes.
|
|
150
|
+
|
|
62
151
|
## Officially Supported Providers
|
|
63
152
|
|
|
64
153
|
### Discord
|
|
@@ -119,6 +208,12 @@ providers: {
|
|
|
119
208
|
## Session Management
|
|
120
209
|
|
|
121
210
|
Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
|
|
211
|
+
Pending OAuth states are held in-memory too, so a restart mid-login, or more
|
|
212
|
+
than one instance behind a load balancer, will reject the callback. Pending
|
|
213
|
+
records are removed when a callback consumes them, not swept on a timer — the
|
|
214
|
+
ten-minute age bound is only evaluated when a matching callback arrives, so an
|
|
215
|
+
abandoned flow's record persists until the process restarts. See
|
|
216
|
+
[#38](https://github.com/abofs/stonyx-oauth/issues/38).
|
|
122
217
|
|
|
123
218
|
Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
|
|
124
219
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
interface AuthorizationRequest {
|
|
3
|
+
url: string;
|
|
4
|
+
bindingValue: string;
|
|
5
|
+
}
|
|
2
6
|
interface OAuthInstance {
|
|
3
7
|
frontendCallbackUrl?: string;
|
|
4
8
|
getSession(sessionId: string): unknown;
|
|
5
|
-
getAuthorizationUrl(providerName: string):
|
|
6
|
-
handleCallback(providerName: string, code: string, stateToken: string): Promise<{
|
|
9
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
10
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<{
|
|
7
11
|
sessionId: string;
|
|
8
12
|
expiresAt: number;
|
|
9
13
|
}>;
|
|
10
14
|
logout(sessionId: string): void;
|
|
11
15
|
}
|
|
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
|
+
}
|
|
12
36
|
interface RouteRequest {
|
|
13
37
|
headers: Record<string, string | undefined>;
|
|
14
38
|
params: Record<string, string>;
|
|
15
39
|
query: Record<string, string>;
|
|
40
|
+
secure?: boolean;
|
|
41
|
+
res?: ResponseLike;
|
|
16
42
|
}
|
|
17
43
|
interface RouteState {
|
|
18
44
|
redirect?: string;
|
|
@@ -23,13 +49,34 @@ export default class AuthRequest extends Request {
|
|
|
23
49
|
handlers: {
|
|
24
50
|
get: {
|
|
25
51
|
'/': ({ headers }: RouteRequest) => {};
|
|
26
|
-
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 | undefined;
|
|
52
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => 404 | 500 | undefined;
|
|
27
53
|
'/callback/:provider': (req: RouteRequest, state: RouteState) => Promise<{
|
|
28
54
|
sessionId: string;
|
|
29
55
|
expiresAt: number;
|
|
30
|
-
} |
|
|
56
|
+
} | 500 | 400 | undefined>;
|
|
31
57
|
'/logout': ({ headers }: RouteRequest) => void;
|
|
32
58
|
};
|
|
33
59
|
};
|
|
60
|
+
cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'>;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
63
|
+
*
|
|
64
|
+
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
65
|
+
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
66
|
+
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
67
|
+
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
68
|
+
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
69
|
+
* cookie would ship without `Secure` while the deployment looks correct.
|
|
70
|
+
*
|
|
71
|
+
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
72
|
+
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
73
|
+
* at the first login and is loud. The alternative fails silently, in
|
|
74
|
+
* production, on the one attribute protecting the value this whole mechanism
|
|
75
|
+
* is built around.
|
|
76
|
+
*/
|
|
77
|
+
isSecureContext(req: RouteRequest): boolean;
|
|
78
|
+
setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
|
|
79
|
+
readBindingCookie(req: RouteRequest): string | undefined;
|
|
80
|
+
clearBindingCookie(req: RouteRequest): void;
|
|
34
81
|
}
|
|
35
82
|
export {};
|
package/dist/auth-request.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
import { STATE_COOKIE_NAME, STATE_COOKIE_PATH, STATE_COOKIE_SAME_SITE, STATE_TTL_MS, } from './constants.js';
|
|
4
|
+
/**
|
|
5
|
+
* Hosts treated as a development origin, and the only ones exempt from
|
|
6
|
+
* `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
|
|
7
|
+
*/
|
|
8
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
2
9
|
export default class AuthRequest extends Request {
|
|
3
10
|
oauth;
|
|
4
11
|
constructor(oauth) {
|
|
@@ -18,17 +25,23 @@ export default class AuthRequest extends Request {
|
|
|
18
25
|
},
|
|
19
26
|
'/login/:provider': (req, state) => {
|
|
20
27
|
const { provider: providerName } = req.params;
|
|
28
|
+
let authorization;
|
|
21
29
|
try {
|
|
22
|
-
|
|
23
|
-
state.redirect = url;
|
|
30
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
24
31
|
}
|
|
25
32
|
catch {
|
|
26
33
|
return 404;
|
|
27
34
|
}
|
|
35
|
+
// Fail closed: a state we cannot bind to this client is exactly the
|
|
36
|
+
// defect this mechanism exists to prevent, so never issue one.
|
|
37
|
+
if (!this.setBindingCookie(req, authorization.bindingValue))
|
|
38
|
+
return 500;
|
|
39
|
+
state.redirect = authorization.url;
|
|
28
40
|
},
|
|
29
41
|
'/callback/:provider': async (req, state) => {
|
|
30
42
|
const { provider: providerName } = req.params;
|
|
31
43
|
const { code, state: stateToken, error } = req.query;
|
|
44
|
+
const bindingValue = this.readBindingCookie(req);
|
|
32
45
|
if (error) {
|
|
33
46
|
if (this.oauth.frontendCallbackUrl) {
|
|
34
47
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
@@ -38,8 +51,15 @@ export default class AuthRequest extends Request {
|
|
|
38
51
|
}
|
|
39
52
|
if (!code)
|
|
40
53
|
return 400;
|
|
54
|
+
// The binding value is single-use, so this callback is the end of that
|
|
55
|
+
// cookie's life — but only from here down, where the state is actually
|
|
56
|
+
// consumed. Clearing above the two early returns denied login to a
|
|
57
|
+
// client still at the provider's consent screen, via an
|
|
58
|
+
// attacker-induced navigation to `?error=...` that needs no knowledge
|
|
59
|
+
// of the victim's state at all.
|
|
60
|
+
this.clearBindingCookie(req);
|
|
41
61
|
try {
|
|
42
|
-
const session = await this.oauth.handleCallback(providerName, code, stateToken);
|
|
62
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
|
|
43
63
|
if (this.oauth.frontendCallbackUrl) {
|
|
44
64
|
const params = new URLSearchParams({
|
|
45
65
|
sessionId: session.sessionId,
|
|
@@ -50,7 +70,14 @@ export default class AuthRequest extends Request {
|
|
|
50
70
|
}
|
|
51
71
|
return session;
|
|
52
72
|
}
|
|
53
|
-
catch {
|
|
73
|
+
catch (rejection) {
|
|
74
|
+
// `StateStore.consume` distinguishes five rejection reasons that
|
|
75
|
+
// otherwise collapse into one opaque outcome with no server-side
|
|
76
|
+
// signal at all. The client-facing `auth_failed` stays opaque; the
|
|
77
|
+
// server has no reason to be. The messages are fixed strings, so
|
|
78
|
+
// nothing caller-controlled reaches the log.
|
|
79
|
+
const reason = rejection instanceof Error ? rejection.message : String(rejection);
|
|
80
|
+
log.error(`OAuth: callback rejected — ${reason}`);
|
|
54
81
|
if (this.oauth.frontendCallbackUrl) {
|
|
55
82
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
56
83
|
return;
|
|
@@ -65,4 +92,86 @@ export default class AuthRequest extends Request {
|
|
|
65
92
|
},
|
|
66
93
|
}
|
|
67
94
|
};
|
|
95
|
+
cookieOptions(req) {
|
|
96
|
+
return {
|
|
97
|
+
httpOnly: true,
|
|
98
|
+
// Load-bearing: the callback is a cross-site top-level GET navigation
|
|
99
|
+
// from the provider. `Strict` withholds the cookie on exactly that
|
|
100
|
+
// request and breaks login outright.
|
|
101
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
102
|
+
path: STATE_COOKIE_PATH,
|
|
103
|
+
secure: this.isSecureContext(req),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
108
|
+
*
|
|
109
|
+
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
110
|
+
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
111
|
+
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
112
|
+
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
113
|
+
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
114
|
+
* cookie would ship without `Secure` while the deployment looks correct.
|
|
115
|
+
*
|
|
116
|
+
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
117
|
+
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
118
|
+
* at the first login and is loud. The alternative fails silently, in
|
|
119
|
+
* production, on the one attribute protecting the value this whole mechanism
|
|
120
|
+
* is built around.
|
|
121
|
+
*/
|
|
122
|
+
isSecureContext(req) {
|
|
123
|
+
if (req.secure === true)
|
|
124
|
+
return true;
|
|
125
|
+
const host = req.headers.host;
|
|
126
|
+
if (!host)
|
|
127
|
+
return true;
|
|
128
|
+
// `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
|
|
129
|
+
const hostname = (host.startsWith('[')
|
|
130
|
+
? host.slice(1, host.indexOf(']'))
|
|
131
|
+
: host.split(':')[0]).toLowerCase();
|
|
132
|
+
if (LOOPBACK_HOSTS.has(hostname))
|
|
133
|
+
return false;
|
|
134
|
+
if (hostname.startsWith('127.'))
|
|
135
|
+
return false;
|
|
136
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost'))
|
|
137
|
+
return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
setBindingCookie(req, bindingValue) {
|
|
141
|
+
const { res } = req;
|
|
142
|
+
if (typeof res?.cookie !== 'function') {
|
|
143
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
147
|
+
...this.cookieOptions(req),
|
|
148
|
+
maxAge: STATE_TTL_MS,
|
|
149
|
+
});
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
readBindingCookie(req) {
|
|
153
|
+
const header = req.headers.cookie;
|
|
154
|
+
if (!header)
|
|
155
|
+
return undefined;
|
|
156
|
+
for (const part of header.split(';')) {
|
|
157
|
+
const separator = part.indexOf('=');
|
|
158
|
+
if (separator === -1)
|
|
159
|
+
continue;
|
|
160
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
|
|
161
|
+
continue;
|
|
162
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
163
|
+
// `encodeURIComponent` never escapes, so a decode buys nothing — and
|
|
164
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
165
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
166
|
+
// callback into a 500 with a stack trace.
|
|
167
|
+
return part.slice(separator + 1).trim();
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
clearBindingCookie(req) {
|
|
172
|
+
const { res } = req;
|
|
173
|
+
if (typeof res?.clearCookie !== 'function')
|
|
174
|
+
return;
|
|
175
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
|
|
176
|
+
}
|
|
68
177
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const STATE_COOKIE_NAME = "stonyx_oauth_state";
|
|
2
|
+
export declare const STATE_COOKIE_PATH = "/auth";
|
|
3
|
+
export declare const STATE_COOKIE_SAME_SITE = "lax";
|
|
4
|
+
/** Lifetime of a pending state record, 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;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Shared constants for the OAuth state/client-binding mechanism (#36).
|
|
2
|
+
//
|
|
3
|
+
// The binding cookie attributes are load-bearing, not cosmetic:
|
|
4
|
+
// - `SameSite=Lax` — the OAuth callback is a cross-site, top-level GET
|
|
5
|
+
// navigation initiated by the provider. `Strict` withholds the cookie on
|
|
6
|
+
// exactly that request and breaks login; `None` requires `Secure` and
|
|
7
|
+
// widens exposure for no benefit. `Lax` is the only correct value.
|
|
8
|
+
// - `Path=/auth` — the cookie is only ever read by the callback route.
|
|
9
|
+
// - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
10
|
+
export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
|
|
11
|
+
export const STATE_COOKIE_PATH = '/auth';
|
|
12
|
+
export const STATE_COOKIE_SAME_SITE = 'lax';
|
|
13
|
+
/** Lifetime of a pending state record, and the binding cookie's Max-Age. */
|
|
14
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
16
|
+
export const BINDING_VALUE_BYTES = 32;
|
package/dist/main.d.ts
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
1
|
import TokenManager from './token-manager.js';
|
|
2
2
|
import SessionManager from './session-manager.js';
|
|
3
|
+
import StateStore from './state-store.js';
|
|
3
4
|
import type OAuthFlow from './oauth-flow.js';
|
|
4
5
|
interface ProviderEntry {
|
|
5
6
|
flow: OAuthFlow;
|
|
6
7
|
tokenManager: TokenManager;
|
|
7
8
|
}
|
|
9
|
+
export interface AuthorizationRequest {
|
|
10
|
+
/** Provider authorization URL to redirect the client to. */
|
|
11
|
+
url: string;
|
|
12
|
+
/**
|
|
13
|
+
* Client-held half of the state binding (#36). The caller must hand this to
|
|
14
|
+
* the client that started the flow — the auth routes set it as an HttpOnly
|
|
15
|
+
* cookie — and present it back to `handleCallback`.
|
|
16
|
+
*/
|
|
17
|
+
bindingValue: string;
|
|
18
|
+
}
|
|
8
19
|
export default class OAuth {
|
|
9
20
|
static instance: OAuth | null;
|
|
10
21
|
providers: Map<string, ProviderEntry>;
|
|
11
|
-
|
|
22
|
+
stateStore: StateStore;
|
|
12
23
|
sessionManager: SessionManager;
|
|
13
24
|
frontendCallbackUrl?: string;
|
|
14
25
|
constructor();
|
|
15
26
|
init(): Promise<void>;
|
|
16
27
|
getProvider(name: string): ProviderEntry;
|
|
17
|
-
getAuthorizationUrl(providerName: string):
|
|
18
|
-
|
|
28
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
29
|
+
/**
|
|
30
|
+
* `bindingValue` is required, not optional (#36). An optional parameter lets
|
|
31
|
+
* an existing three-argument call site keep compiling and then fail at
|
|
32
|
+
* runtime on the first real login; a compile error is the loudest disclosure
|
|
33
|
+
* channel available for this break. It is typed as possibly-undefined
|
|
34
|
+
* because the route handler passes through whatever the client presented,
|
|
35
|
+
* and `StateStore.consume` rejects falsy explicitly.
|
|
36
|
+
*/
|
|
37
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<import("./session-manager.js").SessionResult>;
|
|
19
38
|
getSession(sessionId: string): unknown;
|
|
20
39
|
logout(sessionId: string): void;
|
|
21
40
|
}
|
package/dist/main.js
CHANGED
|
@@ -6,11 +6,12 @@ import RestServer from '@stonyx/rest-server';
|
|
|
6
6
|
import TokenManager from './token-manager.js';
|
|
7
7
|
import SessionManager from './session-manager.js';
|
|
8
8
|
import AuthRequest from './auth-request.js';
|
|
9
|
+
import StateStore from './state-store.js';
|
|
9
10
|
setup(['authenticate']);
|
|
10
11
|
export default class OAuth {
|
|
11
12
|
static instance;
|
|
12
13
|
providers = new Map();
|
|
13
|
-
|
|
14
|
+
stateStore = new StateStore();
|
|
14
15
|
sessionManager;
|
|
15
16
|
frontendCallbackUrl;
|
|
16
17
|
constructor() {
|
|
@@ -47,22 +48,19 @@ export default class OAuth {
|
|
|
47
48
|
}
|
|
48
49
|
getAuthorizationUrl(providerName) {
|
|
49
50
|
const { flow } = this.getProvider(providerName);
|
|
50
|
-
const stateToken =
|
|
51
|
-
|
|
52
|
-
return flow.buildAuthorizationUrl(stateToken);
|
|
51
|
+
const { stateToken, bindingValue } = this.stateStore.issue(providerName);
|
|
52
|
+
return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
throw new Error('State token has expired');
|
|
65
|
-
}
|
|
54
|
+
/**
|
|
55
|
+
* `bindingValue` is required, not optional (#36). An optional parameter lets
|
|
56
|
+
* an existing three-argument call site keep compiling and then fail at
|
|
57
|
+
* runtime on the first real login; a compile error is the loudest disclosure
|
|
58
|
+
* channel available for this break. It is typed as possibly-undefined
|
|
59
|
+
* because the route handler passes through whatever the client presented,
|
|
60
|
+
* and `StateStore.consume` rejects falsy explicitly.
|
|
61
|
+
*/
|
|
62
|
+
async handleCallback(providerName, code, stateToken, bindingValue) {
|
|
63
|
+
this.stateStore.consume(stateToken, providerName, bindingValue);
|
|
66
64
|
const { flow, tokenManager } = this.getProvider(providerName);
|
|
67
65
|
const tokens = await tokenManager.getTokens(code);
|
|
68
66
|
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side record for an OAuth flow that is in progress.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately holds a *digest* of the binding value rather than the value
|
|
5
|
+
* itself: a callback is only accepted when the caller presents the plaintext
|
|
6
|
+
* that hashes to `bindingHash`, so the record on its own unlocks nothing.
|
|
7
|
+
*/
|
|
8
|
+
export interface PendingState {
|
|
9
|
+
provider: string;
|
|
10
|
+
bindingHash: string;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
export interface IssuedState {
|
|
14
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
15
|
+
stateToken: string;
|
|
16
|
+
/** Held by the client that started the flow (a cookie), never by the provider. */
|
|
17
|
+
bindingValue: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Issues and validates OAuth2 `state` tokens bound to the client that started
|
|
21
|
+
* the flow (#36).
|
|
22
|
+
*
|
|
23
|
+
* Presence-plus-age on a process-global map is replay-window limiting, not the
|
|
24
|
+
* CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
|
|
25
|
+
* issued to any visitor validated for any callback, so an attacker could
|
|
26
|
+
* harvest their own state and code and deliver them to a victim, logging the
|
|
27
|
+
* victim in as the attacker. A state is now only accepted when the caller also
|
|
28
|
+
* presents the matching client-held binding value, and only at the provider it
|
|
29
|
+
* was issued for.
|
|
30
|
+
*/
|
|
31
|
+
export default class StateStore {
|
|
32
|
+
pending: Map<string, PendingState>;
|
|
33
|
+
ttl: number;
|
|
34
|
+
constructor(ttl?: number);
|
|
35
|
+
static hash(value: string): string;
|
|
36
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
37
|
+
static digestsMatch(a: string, b: string): boolean;
|
|
38
|
+
issue(provider: string): IssuedState;
|
|
39
|
+
/**
|
|
40
|
+
* Validates and consumes a pending state. Throws on every rejection path.
|
|
41
|
+
*
|
|
42
|
+
* The record is removed as soon as the state is recognised — before the TTL,
|
|
43
|
+
* provider and binding checks — so every state gets exactly one attempt
|
|
44
|
+
* whatever the outcome.
|
|
45
|
+
*
|
|
46
|
+
* That uniformity is the justification, not brute-force resistance:
|
|
47
|
+
* guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
|
|
48
|
+
* not the record survives. What retaining it would buy an attacker is a
|
|
49
|
+
* repeatable, unauthenticated oracle on this endpoint for the state's full
|
|
50
|
+
* lifetime — and the safety of that would then rest entirely on an entropy
|
|
51
|
+
* constant a future change can lower. One attempt per state is a structural
|
|
52
|
+
* property; entropy arithmetic is not.
|
|
53
|
+
*
|
|
54
|
+
* The trade is real: an attacker who already knows a victim's state can burn
|
|
55
|
+
* it, and the victim must restart at `/auth/login/:provider`. That vector is
|
|
56
|
+
* accepted deliberately — it requires the victim's `randomUUID` state, and
|
|
57
|
+
* it is self-healing on retry.
|
|
58
|
+
*/
|
|
59
|
+
consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
|
|
60
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
|
|
3
|
+
/**
|
|
4
|
+
* Issues and validates OAuth2 `state` tokens bound to the client that started
|
|
5
|
+
* the flow (#36).
|
|
6
|
+
*
|
|
7
|
+
* Presence-plus-age on a process-global map is replay-window limiting, not the
|
|
8
|
+
* CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
|
|
9
|
+
* issued to any visitor validated for any callback, so an attacker could
|
|
10
|
+
* harvest their own state and code and deliver them to a victim, logging the
|
|
11
|
+
* victim in as the attacker. A state is now only accepted when the caller also
|
|
12
|
+
* presents the matching client-held binding value, and only at the provider it
|
|
13
|
+
* was issued for.
|
|
14
|
+
*/
|
|
15
|
+
export default class StateStore {
|
|
16
|
+
pending = new Map();
|
|
17
|
+
ttl;
|
|
18
|
+
constructor(ttl = STATE_TTL_MS) {
|
|
19
|
+
this.ttl = ttl;
|
|
20
|
+
}
|
|
21
|
+
static hash(value) {
|
|
22
|
+
return createHash('sha256').update(value).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
25
|
+
static digestsMatch(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
let difference = 0;
|
|
29
|
+
for (let index = 0; index < a.length; index++) {
|
|
30
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
31
|
+
}
|
|
32
|
+
return difference === 0;
|
|
33
|
+
}
|
|
34
|
+
issue(provider) {
|
|
35
|
+
const stateToken = randomUUID();
|
|
36
|
+
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
37
|
+
this.pending.set(stateToken, {
|
|
38
|
+
provider,
|
|
39
|
+
bindingHash: StateStore.hash(bindingValue),
|
|
40
|
+
createdAt: Date.now(),
|
|
41
|
+
});
|
|
42
|
+
return { stateToken, bindingValue };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validates and consumes a pending state. Throws on every rejection path.
|
|
46
|
+
*
|
|
47
|
+
* The record is removed as soon as the state is recognised — before the TTL,
|
|
48
|
+
* provider and binding checks — so every state gets exactly one attempt
|
|
49
|
+
* whatever the outcome.
|
|
50
|
+
*
|
|
51
|
+
* That uniformity is the justification, not brute-force resistance:
|
|
52
|
+
* guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
|
|
53
|
+
* not the record survives. What retaining it would buy an attacker is a
|
|
54
|
+
* repeatable, unauthenticated oracle on this endpoint for the state's full
|
|
55
|
+
* lifetime — and the safety of that would then rest entirely on an entropy
|
|
56
|
+
* constant a future change can lower. One attempt per state is a structural
|
|
57
|
+
* property; entropy arithmetic is not.
|
|
58
|
+
*
|
|
59
|
+
* The trade is real: an attacker who already knows a victim's state can burn
|
|
60
|
+
* it, and the victim must restart at `/auth/login/:provider`. That vector is
|
|
61
|
+
* accepted deliberately — it requires the victim's `randomUUID` state, and
|
|
62
|
+
* it is self-healing on retry.
|
|
63
|
+
*/
|
|
64
|
+
consume(stateToken, provider, bindingValue) {
|
|
65
|
+
if (!stateToken)
|
|
66
|
+
throw new Error('Invalid or missing state token');
|
|
67
|
+
const record = this.pending.get(stateToken);
|
|
68
|
+
if (!record)
|
|
69
|
+
throw new Error('Invalid or missing state token');
|
|
70
|
+
this.pending.delete(stateToken);
|
|
71
|
+
if (Date.now() - record.createdAt > this.ttl)
|
|
72
|
+
throw new Error('State token has expired');
|
|
73
|
+
if (record.provider !== provider)
|
|
74
|
+
throw new Error('State token was not issued for this provider');
|
|
75
|
+
if (!bindingValue)
|
|
76
|
+
throw new Error('Missing state binding value');
|
|
77
|
+
if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
|
|
78
|
+
throw new Error('State token is not bound to this client');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"stonyx-async",
|
|
5
5
|
"stonyx-module"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.1.1-alpha.
|
|
7
|
+
"version": "0.1.1-alpha.17",
|
|
8
8
|
"description": "OAuth2 authentication module for the Stonyx framework",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
"provenance": true
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@stonyx/events": "0.1.1-beta.
|
|
58
|
-
"stonyx": "0.2.3-beta.
|
|
57
|
+
"@stonyx/events": "0.1.1-beta.52",
|
|
58
|
+
"stonyx": "0.2.3-beta.76"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
65
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
66
|
-
"@stonyx/logs": "1.0.1-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.81",
|
|
65
|
+
"@stonyx/utils": "0.2.3-beta.26",
|
|
66
|
+
"@stonyx/logs": "1.0.1-beta.19",
|
|
67
67
|
"@types/qunit": "^2.19.13",
|
|
68
68
|
"@types/sinon": "^21.0.1",
|
|
69
69
|
"qunit": "^2.24.1",
|
package/src/auth-request.ts
CHANGED
|
@@ -1,17 +1,64 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
import {
|
|
4
|
+
STATE_COOKIE_NAME,
|
|
5
|
+
STATE_COOKIE_PATH,
|
|
6
|
+
STATE_COOKIE_SAME_SITE,
|
|
7
|
+
STATE_TTL_MS,
|
|
8
|
+
} from './constants.js';
|
|
9
|
+
|
|
10
|
+
interface AuthorizationRequest {
|
|
11
|
+
url: string;
|
|
12
|
+
bindingValue: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hosts treated as a development origin, and the only ones exempt from
|
|
17
|
+
* `Secure` on the binding cookie. See `AuthRequest.isSecureContext`.
|
|
18
|
+
*/
|
|
19
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
2
20
|
|
|
3
21
|
interface OAuthInstance {
|
|
4
22
|
frontendCallbackUrl?: string;
|
|
5
23
|
getSession(sessionId: string): unknown;
|
|
6
|
-
getAuthorizationUrl(providerName: string):
|
|
7
|
-
handleCallback(
|
|
24
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
25
|
+
handleCallback(
|
|
26
|
+
providerName: string,
|
|
27
|
+
code: string,
|
|
28
|
+
stateToken: string,
|
|
29
|
+
bindingValue: string | undefined,
|
|
30
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
8
31
|
logout(sessionId: string): void;
|
|
9
32
|
}
|
|
10
33
|
|
|
34
|
+
interface CookieOptions {
|
|
35
|
+
httpOnly: boolean;
|
|
36
|
+
sameSite: string;
|
|
37
|
+
path: string;
|
|
38
|
+
secure: boolean;
|
|
39
|
+
maxAge?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The response object Express hangs off the request.
|
|
44
|
+
*
|
|
45
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state` has no
|
|
46
|
+
* affordance for response headers, so setting a cookie means reaching for
|
|
47
|
+
* `req.res`. This is a deliberate, temporary escape hatch — tracked by
|
|
48
|
+
* `abofs/stonyx-rest-server#45`, which adds a first-class header affordance to
|
|
49
|
+
* migrate onto.
|
|
50
|
+
*/
|
|
51
|
+
interface ResponseLike {
|
|
52
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
53
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
11
56
|
interface RouteRequest {
|
|
12
57
|
headers: Record<string, string | undefined>;
|
|
13
58
|
params: Record<string, string>;
|
|
14
59
|
query: Record<string, string>;
|
|
60
|
+
secure?: boolean;
|
|
61
|
+
res?: ResponseLike;
|
|
15
62
|
}
|
|
16
63
|
|
|
17
64
|
interface RouteState {
|
|
@@ -41,18 +88,26 @@ export default class AuthRequest extends Request {
|
|
|
41
88
|
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
42
89
|
const { provider: providerName } = req.params;
|
|
43
90
|
|
|
91
|
+
let authorization: AuthorizationRequest;
|
|
44
92
|
try {
|
|
45
|
-
|
|
46
|
-
state.redirect = url;
|
|
93
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
47
94
|
} catch {
|
|
48
95
|
return 404;
|
|
49
96
|
}
|
|
97
|
+
|
|
98
|
+
// Fail closed: a state we cannot bind to this client is exactly the
|
|
99
|
+
// defect this mechanism exists to prevent, so never issue one.
|
|
100
|
+
if (!this.setBindingCookie(req, authorization.bindingValue)) return 500;
|
|
101
|
+
|
|
102
|
+
state.redirect = authorization.url;
|
|
50
103
|
},
|
|
51
104
|
|
|
52
105
|
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
53
106
|
const { provider: providerName } = req.params;
|
|
54
107
|
const { code, state: stateToken, error } = req.query;
|
|
55
108
|
|
|
109
|
+
const bindingValue = this.readBindingCookie(req);
|
|
110
|
+
|
|
56
111
|
if (error) {
|
|
57
112
|
if (this.oauth.frontendCallbackUrl) {
|
|
58
113
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
@@ -63,8 +118,16 @@ export default class AuthRequest extends Request {
|
|
|
63
118
|
|
|
64
119
|
if (!code) return 400;
|
|
65
120
|
|
|
121
|
+
// The binding value is single-use, so this callback is the end of that
|
|
122
|
+
// cookie's life — but only from here down, where the state is actually
|
|
123
|
+
// consumed. Clearing above the two early returns denied login to a
|
|
124
|
+
// client still at the provider's consent screen, via an
|
|
125
|
+
// attacker-induced navigation to `?error=...` that needs no knowledge
|
|
126
|
+
// of the victim's state at all.
|
|
127
|
+
this.clearBindingCookie(req);
|
|
128
|
+
|
|
66
129
|
try {
|
|
67
|
-
const session = await this.oauth.handleCallback(providerName, code, stateToken);
|
|
130
|
+
const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
|
|
68
131
|
|
|
69
132
|
if (this.oauth.frontendCallbackUrl) {
|
|
70
133
|
const params = new URLSearchParams({
|
|
@@ -76,7 +139,15 @@ export default class AuthRequest extends Request {
|
|
|
76
139
|
}
|
|
77
140
|
|
|
78
141
|
return session;
|
|
79
|
-
} catch {
|
|
142
|
+
} catch (rejection) {
|
|
143
|
+
// `StateStore.consume` distinguishes five rejection reasons that
|
|
144
|
+
// otherwise collapse into one opaque outcome with no server-side
|
|
145
|
+
// signal at all. The client-facing `auth_failed` stays opaque; the
|
|
146
|
+
// server has no reason to be. The messages are fixed strings, so
|
|
147
|
+
// nothing caller-controlled reaches the log.
|
|
148
|
+
const reason = rejection instanceof Error ? rejection.message : String(rejection);
|
|
149
|
+
log.error(`OAuth: callback rejected — ${reason}`);
|
|
150
|
+
|
|
80
151
|
if (this.oauth.frontendCallbackUrl) {
|
|
81
152
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
82
153
|
return;
|
|
@@ -91,4 +162,94 @@ export default class AuthRequest extends Request {
|
|
|
91
162
|
},
|
|
92
163
|
}
|
|
93
164
|
};
|
|
165
|
+
|
|
166
|
+
cookieOptions(req: RouteRequest): Omit<CookieOptions, 'maxAge'> {
|
|
167
|
+
return {
|
|
168
|
+
httpOnly: true,
|
|
169
|
+
// Load-bearing: the callback is a cross-site top-level GET navigation
|
|
170
|
+
// from the provider. `Strict` withholds the cookie on exactly that
|
|
171
|
+
// request and breaks login outright.
|
|
172
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
173
|
+
path: STATE_COOKIE_PATH,
|
|
174
|
+
secure: this.isSecureContext(req),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
180
|
+
*
|
|
181
|
+
* Not `req.secure`. Express derives that from the socket unless `trust proxy`
|
|
182
|
+
* is enabled, and `@stonyx/rest-server` leaves it off by default
|
|
183
|
+
* (`trustProxy: REST_TRUST_PROXY === 'true'`). In the standard production
|
|
184
|
+
* topology — TLS terminated at a proxy, plaintext to the origin — `req.secure`
|
|
185
|
+
* is therefore `false` on every request to an HTTPS site, and the binding
|
|
186
|
+
* cookie would ship without `Secure` while the deployment looks correct.
|
|
187
|
+
*
|
|
188
|
+
* So `Secure` is set unconditionally except on a loopback host. Guessing
|
|
189
|
+
* wrong there breaks a non-loopback plaintext development setup, which fails
|
|
190
|
+
* at the first login and is loud. The alternative fails silently, in
|
|
191
|
+
* production, on the one attribute protecting the value this whole mechanism
|
|
192
|
+
* is built around.
|
|
193
|
+
*/
|
|
194
|
+
isSecureContext(req: RouteRequest): boolean {
|
|
195
|
+
if (req.secure === true) return true;
|
|
196
|
+
|
|
197
|
+
const host = req.headers.host;
|
|
198
|
+
if (!host) return true;
|
|
199
|
+
|
|
200
|
+
// `[::1]:2666` -> `::1`; `localhost:2666` -> `localhost`.
|
|
201
|
+
const hostname = (host.startsWith('[')
|
|
202
|
+
? host.slice(1, host.indexOf(']'))
|
|
203
|
+
: host.split(':')[0]
|
|
204
|
+
).toLowerCase();
|
|
205
|
+
|
|
206
|
+
if (LOOPBACK_HOSTS.has(hostname)) return false;
|
|
207
|
+
if (hostname.startsWith('127.')) return false;
|
|
208
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return false;
|
|
209
|
+
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
|
|
214
|
+
const { res } = req;
|
|
215
|
+
|
|
216
|
+
if (typeof res?.cookie !== 'function') {
|
|
217
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
222
|
+
...this.cookieOptions(req),
|
|
223
|
+
maxAge: STATE_TTL_MS,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
readBindingCookie(req: RouteRequest): string | undefined {
|
|
230
|
+
const header = req.headers.cookie;
|
|
231
|
+
if (!header) return undefined;
|
|
232
|
+
|
|
233
|
+
for (const part of header.split(';')) {
|
|
234
|
+
const separator = part.indexOf('=');
|
|
235
|
+
if (separator === -1) continue;
|
|
236
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
237
|
+
|
|
238
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
239
|
+
// `encodeURIComponent` never escapes, so a decode buys nothing — and
|
|
240
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
241
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
242
|
+
// callback into a 500 with a stack trace.
|
|
243
|
+
return part.slice(separator + 1).trim();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
clearBindingCookie(req: RouteRequest): void {
|
|
250
|
+
const { res } = req;
|
|
251
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
252
|
+
|
|
253
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(req));
|
|
254
|
+
}
|
|
94
255
|
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Shared constants for the OAuth state/client-binding mechanism (#36).
|
|
2
|
+
//
|
|
3
|
+
// The binding cookie attributes are load-bearing, not cosmetic:
|
|
4
|
+
// - `SameSite=Lax` — the OAuth callback is a cross-site, top-level GET
|
|
5
|
+
// navigation initiated by the provider. `Strict` withholds the cookie on
|
|
6
|
+
// exactly that request and breaks login; `None` requires `Secure` and
|
|
7
|
+
// widens exposure for no benefit. `Lax` is the only correct value.
|
|
8
|
+
// - `Path=/auth` — the cookie is only ever read by the callback route.
|
|
9
|
+
// - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
10
|
+
|
|
11
|
+
export const STATE_COOKIE_NAME = 'stonyx_oauth_state';
|
|
12
|
+
export const STATE_COOKIE_PATH = '/auth';
|
|
13
|
+
export const STATE_COOKIE_SAME_SITE = 'lax';
|
|
14
|
+
|
|
15
|
+
/** Lifetime of a pending state record, and the binding cookie's Max-Age. */
|
|
16
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
19
|
+
export const BINDING_VALUE_BYTES = 32;
|
package/src/main.ts
CHANGED
|
@@ -6,6 +6,7 @@ import RestServer from '@stonyx/rest-server';
|
|
|
6
6
|
import TokenManager from './token-manager.js';
|
|
7
7
|
import SessionManager from './session-manager.js';
|
|
8
8
|
import AuthRequest from './auth-request.js';
|
|
9
|
+
import StateStore from './state-store.js';
|
|
9
10
|
import type OAuthFlow from './oauth-flow.js';
|
|
10
11
|
|
|
11
12
|
setup(['authenticate']);
|
|
@@ -20,11 +21,22 @@ interface ProviderConfig {
|
|
|
20
21
|
[key: string]: unknown;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export interface AuthorizationRequest {
|
|
25
|
+
/** Provider authorization URL to redirect the client to. */
|
|
26
|
+
url: string;
|
|
27
|
+
/**
|
|
28
|
+
* Client-held half of the state binding (#36). The caller must hand this to
|
|
29
|
+
* the client that started the flow — the auth routes set it as an HttpOnly
|
|
30
|
+
* cookie — and present it back to `handleCallback`.
|
|
31
|
+
*/
|
|
32
|
+
bindingValue: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
export default class OAuth {
|
|
24
36
|
static instance: OAuth | null;
|
|
25
37
|
|
|
26
38
|
providers = new Map<string, ProviderEntry>();
|
|
27
|
-
|
|
39
|
+
stateStore = new StateStore();
|
|
28
40
|
sessionManager!: SessionManager;
|
|
29
41
|
frontendCallbackUrl?: string;
|
|
30
42
|
|
|
@@ -66,26 +78,28 @@ export default class OAuth {
|
|
|
66
78
|
return provider;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
getAuthorizationUrl(providerName: string):
|
|
81
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest {
|
|
70
82
|
const { flow } = this.getProvider(providerName);
|
|
71
|
-
const stateToken =
|
|
72
|
-
this.pendingStates.set(stateToken, Date.now());
|
|
73
|
-
return flow.buildAuthorizationUrl(stateToken);
|
|
74
|
-
}
|
|
83
|
+
const { stateToken, bindingValue } = this.stateStore.issue(providerName);
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw new Error('Invalid or missing state token');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const stateCreatedAt = this.pendingStates.get(stateToken);
|
|
82
|
-
if (stateCreatedAt === undefined) throw new Error('State token not found in pending states');
|
|
83
|
-
this.pendingStates.delete(stateToken);
|
|
85
|
+
return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
|
|
86
|
+
}
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* `bindingValue` is required, not optional (#36). An optional parameter lets
|
|
90
|
+
* an existing three-argument call site keep compiling and then fail at
|
|
91
|
+
* runtime on the first real login; a compile error is the loudest disclosure
|
|
92
|
+
* channel available for this break. It is typed as possibly-undefined
|
|
93
|
+
* because the route handler passes through whatever the client presented,
|
|
94
|
+
* and `StateStore.consume` rejects falsy explicitly.
|
|
95
|
+
*/
|
|
96
|
+
async handleCallback(
|
|
97
|
+
providerName: string,
|
|
98
|
+
code: string,
|
|
99
|
+
stateToken: string,
|
|
100
|
+
bindingValue: string | undefined,
|
|
101
|
+
) {
|
|
102
|
+
this.stateStore.consume(stateToken, providerName, bindingValue);
|
|
89
103
|
|
|
90
104
|
const { flow, tokenManager } = this.getProvider(providerName);
|
|
91
105
|
const tokens = await tokenManager.getTokens(code);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { BINDING_VALUE_BYTES, STATE_TTL_MS } from './constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server-side record for an OAuth flow that is in progress.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately holds a *digest* of the binding value rather than the value
|
|
8
|
+
* itself: a callback is only accepted when the caller presents the plaintext
|
|
9
|
+
* that hashes to `bindingHash`, so the record on its own unlocks nothing.
|
|
10
|
+
*/
|
|
11
|
+
export interface PendingState {
|
|
12
|
+
provider: string;
|
|
13
|
+
bindingHash: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IssuedState {
|
|
18
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
19
|
+
stateToken: string;
|
|
20
|
+
/** Held by the client that started the flow (a cookie), never by the provider. */
|
|
21
|
+
bindingValue: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Issues and validates OAuth2 `state` tokens bound to the client that started
|
|
26
|
+
* the flow (#36).
|
|
27
|
+
*
|
|
28
|
+
* Presence-plus-age on a process-global map is replay-window limiting, not the
|
|
29
|
+
* CSRF binding `state` exists to provide (RFC 6749 section 10.12): any state
|
|
30
|
+
* issued to any visitor validated for any callback, so an attacker could
|
|
31
|
+
* harvest their own state and code and deliver them to a victim, logging the
|
|
32
|
+
* victim in as the attacker. A state is now only accepted when the caller also
|
|
33
|
+
* presents the matching client-held binding value, and only at the provider it
|
|
34
|
+
* was issued for.
|
|
35
|
+
*/
|
|
36
|
+
export default class StateStore {
|
|
37
|
+
pending = new Map<string, PendingState>();
|
|
38
|
+
ttl: number;
|
|
39
|
+
|
|
40
|
+
constructor(ttl: number = STATE_TTL_MS) {
|
|
41
|
+
this.ttl = ttl;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static hash(value: string): string {
|
|
45
|
+
return createHash('sha256').update(value).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
49
|
+
static digestsMatch(a: string, b: string): boolean {
|
|
50
|
+
if (a.length !== b.length) return false;
|
|
51
|
+
|
|
52
|
+
let difference = 0;
|
|
53
|
+
for (let index = 0; index < a.length; index++) {
|
|
54
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return difference === 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
issue(provider: string): IssuedState {
|
|
61
|
+
const stateToken = randomUUID();
|
|
62
|
+
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
63
|
+
|
|
64
|
+
this.pending.set(stateToken, {
|
|
65
|
+
provider,
|
|
66
|
+
bindingHash: StateStore.hash(bindingValue),
|
|
67
|
+
createdAt: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { stateToken, bindingValue };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates and consumes a pending state. Throws on every rejection path.
|
|
75
|
+
*
|
|
76
|
+
* The record is removed as soon as the state is recognised — before the TTL,
|
|
77
|
+
* provider and binding checks — so every state gets exactly one attempt
|
|
78
|
+
* whatever the outcome.
|
|
79
|
+
*
|
|
80
|
+
* That uniformity is the justification, not brute-force resistance:
|
|
81
|
+
* guessing `BINDING_VALUE_BYTES` of CSPRNG output is infeasible whether or
|
|
82
|
+
* not the record survives. What retaining it would buy an attacker is a
|
|
83
|
+
* repeatable, unauthenticated oracle on this endpoint for the state's full
|
|
84
|
+
* lifetime — and the safety of that would then rest entirely on an entropy
|
|
85
|
+
* constant a future change can lower. One attempt per state is a structural
|
|
86
|
+
* property; entropy arithmetic is not.
|
|
87
|
+
*
|
|
88
|
+
* The trade is real: an attacker who already knows a victim's state can burn
|
|
89
|
+
* it, and the victim must restart at `/auth/login/:provider`. That vector is
|
|
90
|
+
* accepted deliberately — it requires the victim's `randomUUID` state, and
|
|
91
|
+
* it is self-healing on retry.
|
|
92
|
+
*/
|
|
93
|
+
consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
|
|
94
|
+
if (!stateToken) throw new Error('Invalid or missing state token');
|
|
95
|
+
|
|
96
|
+
const record = this.pending.get(stateToken);
|
|
97
|
+
if (!record) throw new Error('Invalid or missing state token');
|
|
98
|
+
this.pending.delete(stateToken);
|
|
99
|
+
|
|
100
|
+
if (Date.now() - record.createdAt > this.ttl) throw new Error('State token has expired');
|
|
101
|
+
if (record.provider !== provider) throw new Error('State token was not issued for this provider');
|
|
102
|
+
if (!bindingValue) throw new Error('Missing state binding value');
|
|
103
|
+
|
|
104
|
+
if (!StateStore.digestsMatch(StateStore.hash(bindingValue), record.bindingHash)) {
|
|
105
|
+
throw new Error('State token is not bound to this client');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/types/node.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
declare module 'node:crypto' {
|
|
2
|
+
interface Hash {
|
|
3
|
+
update(data: string): Hash;
|
|
4
|
+
digest(encoding: 'hex'): string;
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
export function randomUUID(): string;
|
|
8
|
+
export function randomBytes(size: number): { toString(encoding: 'base64url' | 'hex'): string };
|
|
9
|
+
export function createHash(algorithm: string): Hash;
|
|
3
10
|
}
|
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
|
}
|