@stonyx/oauth 0.1.1-alpha.16 → 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 +36 -7
- package/dist/auth-request.d.ts +18 -1
- package/dist/auth-request.js +61 -6
- package/dist/main.d.ts +9 -1
- package/dist/main.js +8 -0
- package/dist/state-store.d.ts +16 -3
- package/dist/state-store.js +16 -3
- package/package.json +2 -6
- package/src/auth-request.ts +66 -7
- package/src/main.ts +14 -1
- package/src/state-store.ts +16 -3
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.
|
|
@@ -86,7 +86,7 @@ issue time, and sends the plaintext to the client as a cookie:
|
|
|
86
86
|
| `HttpOnly` | set | script must not be able to read or forge the binding value |
|
|
87
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
88
|
| `Path` | `/auth` | the cookie is only ever read by the callback route |
|
|
89
|
-
| `Secure` | set
|
|
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
90
|
| `Max-Age` | 600 (10 minutes) | matches the pending state's lifetime |
|
|
91
91
|
|
|
92
92
|
`GET /auth/callback/:provider` accepts the callback only when all of the
|
|
@@ -101,10 +101,35 @@ The state and the cookie are both single-use: the pending record is consumed on
|
|
|
101
101
|
any callback that presents a recognised `state` — successful or not — and the
|
|
102
102
|
callback response clears the cookie.
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
108
133
|
|
|
109
134
|
### Custom flow drivers
|
|
110
135
|
|
|
@@ -184,7 +209,11 @@ providers: {
|
|
|
184
209
|
|
|
185
210
|
Sessions are stored in-memory using a `Map`. Sessions are lost on server restart.
|
|
186
211
|
Pending OAuth states are held in-memory too, so a restart mid-login, or more
|
|
187
|
-
than one instance behind a load balancer, will reject the callback.
|
|
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).
|
|
188
217
|
|
|
189
218
|
Clients should store the `sessionId` returned from the callback and send it as a `session-id` header on subsequent requests.
|
|
190
219
|
|
package/dist/auth-request.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ interface OAuthInstance {
|
|
|
7
7
|
frontendCallbackUrl?: string;
|
|
8
8
|
getSession(sessionId: string): unknown;
|
|
9
9
|
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
10
|
-
handleCallback(providerName: string, code: string, stateToken: string, bindingValue
|
|
10
|
+
handleCallback(providerName: string, code: string, stateToken: string, bindingValue: string | undefined): Promise<{
|
|
11
11
|
sessionId: string;
|
|
12
12
|
expiresAt: number;
|
|
13
13
|
}>;
|
|
@@ -58,6 +58,23 @@ export default class AuthRequest extends Request {
|
|
|
58
58
|
};
|
|
59
59
|
};
|
|
60
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;
|
|
61
78
|
setBindingCookie(req: RouteRequest, bindingValue: string): boolean;
|
|
62
79
|
readBindingCookie(req: RouteRequest): string | undefined;
|
|
63
80
|
clearBindingCookie(req: RouteRequest): void;
|
package/dist/auth-request.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
2
|
import log from 'stonyx/log';
|
|
3
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']);
|
|
4
9
|
export default class AuthRequest extends Request {
|
|
5
10
|
oauth;
|
|
6
11
|
constructor(oauth) {
|
|
@@ -36,10 +41,7 @@ export default class AuthRequest extends Request {
|
|
|
36
41
|
'/callback/:provider': async (req, state) => {
|
|
37
42
|
const { provider: providerName } = req.params;
|
|
38
43
|
const { code, state: stateToken, error } = req.query;
|
|
39
|
-
// The binding value is single-use: whatever the outcome below, this
|
|
40
|
-
// callback is the end of that cookie's life.
|
|
41
44
|
const bindingValue = this.readBindingCookie(req);
|
|
42
|
-
this.clearBindingCookie(req);
|
|
43
45
|
if (error) {
|
|
44
46
|
if (this.oauth.frontendCallbackUrl) {
|
|
45
47
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
@@ -49,6 +51,13 @@ export default class AuthRequest extends Request {
|
|
|
49
51
|
}
|
|
50
52
|
if (!code)
|
|
51
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);
|
|
52
61
|
try {
|
|
53
62
|
const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
|
|
54
63
|
if (this.oauth.frontendCallbackUrl) {
|
|
@@ -61,7 +70,14 @@ export default class AuthRequest extends Request {
|
|
|
61
70
|
}
|
|
62
71
|
return session;
|
|
63
72
|
}
|
|
64
|
-
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}`);
|
|
65
81
|
if (this.oauth.frontendCallbackUrl) {
|
|
66
82
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
67
83
|
return;
|
|
@@ -84,9 +100,43 @@ export default class AuthRequest extends Request {
|
|
|
84
100
|
// request and breaks login outright.
|
|
85
101
|
sameSite: STATE_COOKIE_SAME_SITE,
|
|
86
102
|
path: STATE_COOKIE_PATH,
|
|
87
|
-
secure: req
|
|
103
|
+
secure: this.isSecureContext(req),
|
|
88
104
|
};
|
|
89
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
|
+
}
|
|
90
140
|
setBindingCookie(req, bindingValue) {
|
|
91
141
|
const { res } = req;
|
|
92
142
|
if (typeof res?.cookie !== 'function') {
|
|
@@ -109,7 +159,12 @@ export default class AuthRequest extends Request {
|
|
|
109
159
|
continue;
|
|
110
160
|
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME)
|
|
111
161
|
continue;
|
|
112
|
-
|
|
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();
|
|
113
168
|
}
|
|
114
169
|
return undefined;
|
|
115
170
|
}
|
package/dist/main.d.ts
CHANGED
|
@@ -26,7 +26,15 @@ export default class OAuth {
|
|
|
26
26
|
init(): Promise<void>;
|
|
27
27
|
getProvider(name: string): ProviderEntry;
|
|
28
28
|
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
29
|
-
|
|
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>;
|
|
30
38
|
getSession(sessionId: string): unknown;
|
|
31
39
|
logout(sessionId: string): void;
|
|
32
40
|
}
|
package/dist/main.js
CHANGED
|
@@ -51,6 +51,14 @@ export default class OAuth {
|
|
|
51
51
|
const { stateToken, bindingValue } = this.stateStore.issue(providerName);
|
|
52
52
|
return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
|
|
53
53
|
}
|
|
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
|
+
*/
|
|
54
62
|
async handleCallback(providerName, code, stateToken, bindingValue) {
|
|
55
63
|
this.stateStore.consume(stateToken, providerName, bindingValue);
|
|
56
64
|
const { flow, tokenManager } = this.getProvider(providerName);
|
package/dist/state-store.d.ts
CHANGED
|
@@ -39,9 +39,22 @@ export default class StateStore {
|
|
|
39
39
|
/**
|
|
40
40
|
* Validates and consumes a pending state. Throws on every rejection path.
|
|
41
41
|
*
|
|
42
|
-
* The record is removed as soon as the state is recognised — before the
|
|
43
|
-
* binding
|
|
44
|
-
*
|
|
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.
|
|
45
58
|
*/
|
|
46
59
|
consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void;
|
|
47
60
|
}
|
package/dist/state-store.js
CHANGED
|
@@ -44,9 +44,22 @@ export default class StateStore {
|
|
|
44
44
|
/**
|
|
45
45
|
* Validates and consumes a pending state. Throws on every rejection path.
|
|
46
46
|
*
|
|
47
|
-
* The record is removed as soon as the state is recognised — before the
|
|
48
|
-
* binding
|
|
49
|
-
*
|
|
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.
|
|
50
63
|
*/
|
|
51
64
|
consume(stateToken, provider, bindingValue) {
|
|
52
65
|
if (!stateToken)
|
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",
|
|
@@ -17,10 +17,6 @@
|
|
|
17
17
|
"types": "./dist/main.d.ts",
|
|
18
18
|
"default": "./dist/main.js"
|
|
19
19
|
},
|
|
20
|
-
"./constants": {
|
|
21
|
-
"types": "./dist/constants.d.ts",
|
|
22
|
-
"default": "./dist/constants.js"
|
|
23
|
-
},
|
|
24
20
|
"./oauth-flow": {
|
|
25
21
|
"types": "./dist/oauth-flow.d.ts",
|
|
26
22
|
"default": "./dist/oauth-flow.js"
|
|
@@ -65,7 +61,7 @@
|
|
|
65
61
|
"@stonyx/rest-server": ">=0.2.1-beta.11"
|
|
66
62
|
},
|
|
67
63
|
"devDependencies": {
|
|
68
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
64
|
+
"@stonyx/rest-server": "0.2.1-beta.81",
|
|
69
65
|
"@stonyx/utils": "0.2.3-beta.26",
|
|
70
66
|
"@stonyx/logs": "1.0.1-beta.19",
|
|
71
67
|
"@types/qunit": "^2.19.13",
|
package/src/auth-request.ts
CHANGED
|
@@ -12,6 +12,12 @@ interface AuthorizationRequest {
|
|
|
12
12
|
bindingValue: string;
|
|
13
13
|
}
|
|
14
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']);
|
|
20
|
+
|
|
15
21
|
interface OAuthInstance {
|
|
16
22
|
frontendCallbackUrl?: string;
|
|
17
23
|
getSession(sessionId: string): unknown;
|
|
@@ -20,7 +26,7 @@ interface OAuthInstance {
|
|
|
20
26
|
providerName: string,
|
|
21
27
|
code: string,
|
|
22
28
|
stateToken: string,
|
|
23
|
-
bindingValue
|
|
29
|
+
bindingValue: string | undefined,
|
|
24
30
|
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
25
31
|
logout(sessionId: string): void;
|
|
26
32
|
}
|
|
@@ -100,10 +106,7 @@ export default class AuthRequest extends Request {
|
|
|
100
106
|
const { provider: providerName } = req.params;
|
|
101
107
|
const { code, state: stateToken, error } = req.query;
|
|
102
108
|
|
|
103
|
-
// The binding value is single-use: whatever the outcome below, this
|
|
104
|
-
// callback is the end of that cookie's life.
|
|
105
109
|
const bindingValue = this.readBindingCookie(req);
|
|
106
|
-
this.clearBindingCookie(req);
|
|
107
110
|
|
|
108
111
|
if (error) {
|
|
109
112
|
if (this.oauth.frontendCallbackUrl) {
|
|
@@ -115,6 +118,14 @@ export default class AuthRequest extends Request {
|
|
|
115
118
|
|
|
116
119
|
if (!code) return 400;
|
|
117
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
|
+
|
|
118
129
|
try {
|
|
119
130
|
const session = await this.oauth.handleCallback(providerName, code, stateToken, bindingValue);
|
|
120
131
|
|
|
@@ -128,7 +139,15 @@ export default class AuthRequest extends Request {
|
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
return session;
|
|
131
|
-
} 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
|
+
|
|
132
151
|
if (this.oauth.frontendCallbackUrl) {
|
|
133
152
|
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
134
153
|
return;
|
|
@@ -152,10 +171,45 @@ export default class AuthRequest extends Request {
|
|
|
152
171
|
// request and breaks login outright.
|
|
153
172
|
sameSite: STATE_COOKIE_SAME_SITE,
|
|
154
173
|
path: STATE_COOKIE_PATH,
|
|
155
|
-
secure: req
|
|
174
|
+
secure: this.isSecureContext(req),
|
|
156
175
|
};
|
|
157
176
|
}
|
|
158
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
|
+
|
|
159
213
|
setBindingCookie(req: RouteRequest, bindingValue: string): boolean {
|
|
160
214
|
const { res } = req;
|
|
161
215
|
|
|
@@ -181,7 +235,12 @@ export default class AuthRequest extends Request {
|
|
|
181
235
|
if (separator === -1) continue;
|
|
182
236
|
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
183
237
|
|
|
184
|
-
|
|
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();
|
|
185
244
|
}
|
|
186
245
|
|
|
187
246
|
return undefined;
|
package/src/main.ts
CHANGED
|
@@ -85,7 +85,20 @@ export default class OAuth {
|
|
|
85
85
|
return { url: flow.buildAuthorizationUrl(stateToken), bindingValue };
|
|
86
86
|
}
|
|
87
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
|
+
) {
|
|
89
102
|
this.stateStore.consume(stateToken, providerName, bindingValue);
|
|
90
103
|
|
|
91
104
|
const { flow, tokenManager } = this.getProvider(providerName);
|
package/src/state-store.ts
CHANGED
|
@@ -73,9 +73,22 @@ export default class StateStore {
|
|
|
73
73
|
/**
|
|
74
74
|
* Validates and consumes a pending state. Throws on every rejection path.
|
|
75
75
|
*
|
|
76
|
-
* The record is removed as soon as the state is recognised — before the
|
|
77
|
-
* binding
|
|
78
|
-
*
|
|
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.
|
|
79
92
|
*/
|
|
80
93
|
consume(stateToken: string | undefined, provider: string, bindingValue: string | undefined): void {
|
|
81
94
|
if (!stateToken) throw new Error('Invalid or missing state token');
|