@stonyx/oauth 0.1.1-beta.2 → 0.1.1-beta.200
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 +193 -2
- package/dist/auth-request.d.ts +147 -0
- package/dist/auth-request.js +247 -0
- package/dist/main.d.ts +121 -0
- package/dist/main.js +194 -0
- package/dist/oauth-flow.d.ts +30 -0
- package/dist/oauth-flow.js +83 -0
- package/dist/providers/discord.d.ts +30 -0
- package/dist/providers/discord.js +43 -0
- package/dist/session-manager.d.ts +20 -0
- package/dist/session-manager.js +30 -0
- package/dist/ticket-store.d.ts +134 -0
- package/dist/ticket-store.js +140 -0
- package/dist/token-manager.d.ts +15 -0
- package/dist/token-manager.js +24 -0
- package/package.json +45 -8
- package/src/auth-request.ts +348 -0
- package/src/main.ts +259 -0
- package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
- package/src/providers/{discord.js → discord.ts} +29 -3
- package/src/{session-manager.js → session-manager.ts} +19 -6
- package/src/ticket-store.ts +158 -0
- package/src/token-manager.ts +35 -0
- package/src/types/node.d.ts +19 -0
- package/src/types/stonyx-events.d.ts +4 -0
- package/src/types/stonyx-rest-server.d.ts +11 -0
- package/src/types/stonyx.d.ts +38 -0
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/src/auth-request.js +0 -74
- package/src/main.js +0 -79
- package/src/token-manager.js +0 -26
- package/test/config/environment.js +0 -18
- package/test/integration/oauth-test.js +0 -149
- package/test/sample/providers/mock.js +0 -40
- package/test/sample/requests/.gitkeep +0 -0
- package/test/unit/oauth-flow-test.js +0 -137
- package/test/unit/providers/discord-test.js +0 -115
- package/test/unit/session-manager-test.js +0 -85
- package/test/unit/state-validation-test.js +0 -118
- package/test/unit/token-manager-test.js +0 -76
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { Request } from '@stonyx/rest-server';
|
|
2
|
+
import log from 'stonyx/log';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The cookie carrying the client-held half of the OAuth2 `state` binding (#36).
|
|
6
|
+
*
|
|
7
|
+
* The attributes below are load-bearing, not cosmetic:
|
|
8
|
+
*
|
|
9
|
+
* - `SameSite=Lax` — the callback is a cross-site, top-level GET navigation
|
|
10
|
+
* initiated by the provider. `Strict` withholds the cookie on exactly that
|
|
11
|
+
* request, breaking 100% of logins while passing every CSRF test; `None`
|
|
12
|
+
* requires `Secure` and widens exposure for no benefit.
|
|
13
|
+
* - `Path=/` — routing is case-insensitive today
|
|
14
|
+
* (`abofs/stonyx-rest-server#47`: `GET /AUTH/login/discord` redirects) but
|
|
15
|
+
* RFC 6265 section 5.1.4 `Path` matching is case-sensitive, so a narrow
|
|
16
|
+
* `/auth` silently drops the cookie on a case-varied callback and breaks
|
|
17
|
+
* login.
|
|
18
|
+
* - `HttpOnly` — script must not be able to read or forge the binding value.
|
|
19
|
+
*/
|
|
20
|
+
const STATE_COOKIE_NAME = 'oauth_state';
|
|
21
|
+
const STATE_COOKIE_PATH = '/';
|
|
22
|
+
const STATE_COOKIE_SAME_SITE = 'lax';
|
|
23
|
+
|
|
24
|
+
interface AuthorizationRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
stateToken: string;
|
|
27
|
+
bindingValue: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OAuthInstance {
|
|
31
|
+
frontendCallbackUrl?: string;
|
|
32
|
+
stateTtl: number;
|
|
33
|
+
getSession(sessionId: string): unknown;
|
|
34
|
+
getAuthorizationUrl(providerName: string): AuthorizationRequest;
|
|
35
|
+
discardState(stateToken: string): void;
|
|
36
|
+
redirectUriFor(providerName: string): string | undefined;
|
|
37
|
+
handleCallback(
|
|
38
|
+
providerName: string,
|
|
39
|
+
code: string,
|
|
40
|
+
stateToken: string,
|
|
41
|
+
bindingValues: readonly string[],
|
|
42
|
+
): Promise<{ sessionId: string; expiresAt: number }>;
|
|
43
|
+
issueExchangeTicket(session: { sessionId: string; expiresAt: number }): string;
|
|
44
|
+
redeemExchangeTicket(ticket: string): { sessionId: string; expiresAt: number } | null;
|
|
45
|
+
logout(sessionId: string): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CookieOptions {
|
|
49
|
+
httpOnly: boolean;
|
|
50
|
+
sameSite: string;
|
|
51
|
+
path: string;
|
|
52
|
+
secure: boolean;
|
|
53
|
+
maxAge?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The response object express hangs off the request.
|
|
58
|
+
*
|
|
59
|
+
* `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
|
|
60
|
+
* is unreachable once `state.redirect` is set (`request.ts` returns on the
|
|
61
|
+
* redirect first), so setting a cookie means reaching for `req.res`.
|
|
62
|
+
*
|
|
63
|
+
* This is a deliberate, sanctioned interim reach-around, not an accident:
|
|
64
|
+
* `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
|
|
65
|
+
* first-class header/cookie affordance to migrate onto, and it is sequenced
|
|
66
|
+
* after this fix. `setBindingCookie` fails closed if the affordance is not
|
|
67
|
+
* there, which is what contains the dependency.
|
|
68
|
+
*/
|
|
69
|
+
interface ResponseLike {
|
|
70
|
+
cookie(name: string, value: string, options: CookieOptions): unknown;
|
|
71
|
+
clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
|
|
72
|
+
/**
|
|
73
|
+
* Optional: every call site guards on it. `@stonyx/rest-server` hands the
|
|
74
|
+
* express response through untyped, and a test double need not implement the
|
|
75
|
+
* whole surface.
|
|
76
|
+
*/
|
|
77
|
+
setHeader?(name: string, value: string): unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RouteRequest {
|
|
81
|
+
headers: Record<string, string | undefined>;
|
|
82
|
+
params: Record<string, string>;
|
|
83
|
+
query: Record<string, string>;
|
|
84
|
+
/**
|
|
85
|
+
* Parsed by `express.json()`, which `@stonyx/rest-server` installs globally.
|
|
86
|
+
*
|
|
87
|
+
* Optional and typed loosely because it is whatever an unauthenticated
|
|
88
|
+
* caller sent: a form-encoded body arrives as `null` and a bodyless request
|
|
89
|
+
* as `undefined`, so every read of it has to survive both.
|
|
90
|
+
*/
|
|
91
|
+
body?: unknown;
|
|
92
|
+
res?: ResponseLike;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface RouteState {
|
|
96
|
+
redirect?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default class AuthRequest extends Request {
|
|
100
|
+
oauth: OAuthInstance;
|
|
101
|
+
|
|
102
|
+
constructor(oauth: OAuthInstance) {
|
|
103
|
+
super();
|
|
104
|
+
this.oauth = oauth;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
handlers = {
|
|
108
|
+
get: {
|
|
109
|
+
'/': ({ headers }: RouteRequest) => {
|
|
110
|
+
const sessionId = headers['session-id'];
|
|
111
|
+
if (!sessionId) return 401;
|
|
112
|
+
|
|
113
|
+
const user = this.oauth.getSession(sessionId);
|
|
114
|
+
if (!user) return 401;
|
|
115
|
+
|
|
116
|
+
return user;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
'/login/:provider': (req: RouteRequest, state: RouteState) => {
|
|
120
|
+
const { provider: providerName } = req.params;
|
|
121
|
+
|
|
122
|
+
let authorization: AuthorizationRequest;
|
|
123
|
+
try {
|
|
124
|
+
authorization = this.oauth.getAuthorizationUrl(providerName);
|
|
125
|
+
} catch {
|
|
126
|
+
return 404;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fail closed. A state we cannot bind to this client is exactly the
|
|
130
|
+
// defect this mechanism exists to prevent, so it is withdrawn rather
|
|
131
|
+
// than issued unbindable.
|
|
132
|
+
if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
|
|
133
|
+
this.oauth.discardState(authorization.stateToken);
|
|
134
|
+
return 500;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
state.redirect = authorization.url;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
'/callback/:provider': async (req: RouteRequest, state: RouteState) => {
|
|
141
|
+
const { provider: providerName } = req.params;
|
|
142
|
+
const { code, state: stateToken, error } = req.query;
|
|
143
|
+
|
|
144
|
+
if (error) {
|
|
145
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
146
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
return 400;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!code) return 400;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const session = await this.oauth.handleCallback(
|
|
156
|
+
providerName,
|
|
157
|
+
code,
|
|
158
|
+
stateToken,
|
|
159
|
+
this.readBindingCookies(req),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Cleared only here, on the success path, which is the only path that
|
|
163
|
+
// is certain to have consumed a state belonging to *this* client.
|
|
164
|
+
//
|
|
165
|
+
// Clearing on failure instead looks harmless and is not: `code` is
|
|
166
|
+
// attacker-supplied and unvalidated, so a bare `?code=1` — no
|
|
167
|
+
// knowledge of anyone's state — would delete the binding cookie of a
|
|
168
|
+
// client still sitting on the provider's consent screen, leaving
|
|
169
|
+
// their pending state untouched so nothing is detectable
|
|
170
|
+
// server-side, and their real callback then fails.
|
|
171
|
+
this.clearBindingCookie(req, providerName);
|
|
172
|
+
|
|
173
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
174
|
+
// The session id is the bearer credential (`GET /auth` above
|
|
175
|
+
// authenticates from exactly this value), so it must not be
|
|
176
|
+
// written into a URL: URLs land in browser history, in `Referer`
|
|
177
|
+
// on any outbound link, in proxy and CDN access logs, and in
|
|
178
|
+
// `location.search` for every script on the landing page. What
|
|
179
|
+
// goes in the URL instead is a single-use 60-second ticket that
|
|
180
|
+
// authenticates nothing, redeemed at `POST /auth/session` (#45).
|
|
181
|
+
//
|
|
182
|
+
// The ticket rides in the *fragment*, not the query. A fragment is
|
|
183
|
+
// never transmitted to any server by any user agent: it is absent
|
|
184
|
+
// from the frontend's own access logs, from every reverse proxy
|
|
185
|
+
// and CDN in front of the landing page, and from `Referer` under
|
|
186
|
+
// every referrer policy. That removes two of the four leak vectors
|
|
187
|
+
// #45 names outright, for one character. What it does not remove
|
|
188
|
+
// is browser history and readability by page scripts — those are
|
|
189
|
+
// why the ticket is still single-use and 60-second, and why the
|
|
190
|
+
// documented migration scrubs it with `history.replaceState`.
|
|
191
|
+
//
|
|
192
|
+
// `expiresAt` rides along in the same fragment rather than staying
|
|
193
|
+
// in the query, so the consumer has one place to read from.
|
|
194
|
+
// It is not a credential and nothing authenticates from it.
|
|
195
|
+
const params = new URLSearchParams({
|
|
196
|
+
ticket: this.oauth.issueExchangeTicket(session),
|
|
197
|
+
expiresAt: String(session.expiresAt),
|
|
198
|
+
});
|
|
199
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}#${params}`;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// No `frontendCallbackUrl` configured: the session is the response
|
|
204
|
+
// body of a direct request, not a value handed to a browser through
|
|
205
|
+
// a URL, so there is nothing here for #45 to fix.
|
|
206
|
+
return session;
|
|
207
|
+
} catch {
|
|
208
|
+
if (this.oauth.frontendCallbackUrl) {
|
|
209
|
+
state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
return 500;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
'/logout': ({ headers }: RouteRequest) => {
|
|
217
|
+
const sessionId = headers['session-id'];
|
|
218
|
+
if (sessionId) this.oauth.logout(sessionId);
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
post: {
|
|
223
|
+
/**
|
|
224
|
+
* Redeems the exchange ticket from the callback redirect (#45).
|
|
225
|
+
*
|
|
226
|
+
* `POST` and not `GET` because a `GET` would put the ticket back in a
|
|
227
|
+
* URL — in the caller's history, in access logs — which is the defect
|
|
228
|
+
* this route exists to close.
|
|
229
|
+
*
|
|
230
|
+
* `application/json` and not form-encoded: `@stonyx/rest-server`
|
|
231
|
+
* installs `express.json()` only, so a form-encoded body arrives as
|
|
232
|
+
* `null` and the ticket is unreadable. Measured, not assumed.
|
|
233
|
+
*
|
|
234
|
+
* Unknown, spent and expired tickets are one indistinguishable `400`.
|
|
235
|
+
*
|
|
236
|
+
* `Cache-Control: no-store` because the `200` body is the session id —
|
|
237
|
+
* the bearer credential itself. A `POST` response is not cacheable
|
|
238
|
+
* without explicit freshness, so this is defence in depth rather than a
|
|
239
|
+
* live defect: it is there so that no intermediary, service worker or
|
|
240
|
+
* future `GET` variant of this route can retain the credential. Set
|
|
241
|
+
* through `req.res`, the same reach-through the binding-cookie helpers
|
|
242
|
+
* use, because `@stonyx/rest-server` has no supported way for a handler
|
|
243
|
+
* to set a response header (`abofs/stonyx-rest-server#45`).
|
|
244
|
+
*/
|
|
245
|
+
'/session': (req: RouteRequest) => {
|
|
246
|
+
const { body, res } = req;
|
|
247
|
+
if (typeof res?.setHeader === 'function') res.setHeader('Cache-Control', 'no-store');
|
|
248
|
+
|
|
249
|
+
const ticket = (body as { ticket?: unknown } | null | undefined)?.ticket;
|
|
250
|
+
if (typeof ticket !== 'string' || !ticket) return 400;
|
|
251
|
+
|
|
252
|
+
const session = this.oauth.redeemExchangeTicket(ticket);
|
|
253
|
+
if (!session) return 400;
|
|
254
|
+
|
|
255
|
+
return { sessionId: session.sessionId, expiresAt: session.expiresAt };
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Whether the binding cookie is issued with `Secure`.
|
|
262
|
+
*
|
|
263
|
+
* Derived from the scheme of the provider's configured `redirectUri`, which
|
|
264
|
+
* is the deployment's own statement of the origin this cookie has to survive
|
|
265
|
+
* a round trip to.
|
|
266
|
+
*
|
|
267
|
+
* Not `req.secure`: express derives that from the socket unless `trust proxy`
|
|
268
|
+
* is on, and `@stonyx/rest-server` leaves it off by default, so in the
|
|
269
|
+
* standard production topology — TLS terminated at a proxy, plaintext to the
|
|
270
|
+
* origin — `req.secure` is `false` on every request to an HTTPS site and the
|
|
271
|
+
* cookie would ship without `Secure` while the deployment looks correct. Not
|
|
272
|
+
* the `Host` header either: that is attacker-controllable on any non-browser
|
|
273
|
+
* client. And not hardcoded `true`, which breaks plaintext local development.
|
|
274
|
+
*
|
|
275
|
+
* An unparseable or absent redirect URI fails secure.
|
|
276
|
+
*/
|
|
277
|
+
isSecureContext(providerName: string): boolean {
|
|
278
|
+
const redirectUri = this.oauth.redirectUriFor(providerName);
|
|
279
|
+
if (!redirectUri) return true;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
return new URL(redirectUri).protocol !== 'http:';
|
|
283
|
+
} catch {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
|
|
289
|
+
return {
|
|
290
|
+
httpOnly: true,
|
|
291
|
+
sameSite: STATE_COOKIE_SAME_SITE,
|
|
292
|
+
path: STATE_COOKIE_PATH,
|
|
293
|
+
secure: this.isSecureContext(providerName),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
|
|
298
|
+
const { res } = req;
|
|
299
|
+
|
|
300
|
+
if (typeof res?.cookie !== 'function') {
|
|
301
|
+
log.error('OAuth: unable to set the state binding cookie; login rejected');
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
res.cookie(STATE_COOKIE_NAME, bindingValue, {
|
|
306
|
+
...this.cookieOptions(providerName),
|
|
307
|
+
maxAge: this.oauth.stateTtl,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Every value the client presented under the binding cookie's name.
|
|
315
|
+
*
|
|
316
|
+
* Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
|
|
317
|
+
* either would hand an attacker a permanent, unauthenticated denial of login
|
|
318
|
+
* for any victim they can plant a same-named cookie on.
|
|
319
|
+
*/
|
|
320
|
+
readBindingCookies(req: RouteRequest): string[] {
|
|
321
|
+
const header = req.headers.cookie;
|
|
322
|
+
if (!header) return [];
|
|
323
|
+
|
|
324
|
+
const values: string[] = [];
|
|
325
|
+
|
|
326
|
+
for (const part of header.split(';')) {
|
|
327
|
+
const separator = part.indexOf('=');
|
|
328
|
+
if (separator === -1) continue;
|
|
329
|
+
if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
|
|
330
|
+
|
|
331
|
+
// Not decoded. The binding value is base64url, whose alphabet
|
|
332
|
+
// `encodeURIComponent` never escapes, so decoding buys nothing — and
|
|
333
|
+
// `decodeURIComponent` throws `URIError` on malformed input, which any
|
|
334
|
+
// unauthenticated caller can supply, turning the first line of the
|
|
335
|
+
// callback into a 500.
|
|
336
|
+
values.push(part.slice(separator + 1).trim());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return values;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
clearBindingCookie(req: RouteRequest, providerName: string): void {
|
|
343
|
+
const { res } = req;
|
|
344
|
+
if (typeof res?.clearCookie !== 'function') return;
|
|
345
|
+
|
|
346
|
+
res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
|
|
347
|
+
}
|
|
348
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import config from 'stonyx/config';
|
|
3
|
+
import log from 'stonyx/log';
|
|
4
|
+
import { waitForModule } from 'stonyx';
|
|
5
|
+
import { setup, emit } from '@stonyx/events';
|
|
6
|
+
import RestServer from '@stonyx/rest-server';
|
|
7
|
+
import TokenManager from './token-manager.js';
|
|
8
|
+
import SessionManager from './session-manager.js';
|
|
9
|
+
import TicketStore from './ticket-store.js';
|
|
10
|
+
import AuthRequest from './auth-request.js';
|
|
11
|
+
import type { RedeemedTicket } from './ticket-store.js';
|
|
12
|
+
import type { SessionResult } from './session-manager.js';
|
|
13
|
+
import type OAuthFlow from './oauth-flow.js';
|
|
14
|
+
|
|
15
|
+
setup(['authenticate']);
|
|
16
|
+
|
|
17
|
+
/** Lifetime of a pending state, and the binding cookie's `Max-Age`. */
|
|
18
|
+
export const STATE_TTL_MS = 10 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
/** Entropy of the client-held binding value, in bytes. */
|
|
21
|
+
export const BINDING_VALUE_BYTES = 32;
|
|
22
|
+
|
|
23
|
+
interface ProviderEntry {
|
|
24
|
+
flow: OAuthFlow;
|
|
25
|
+
tokenManager: TokenManager;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A flow that is in progress.
|
|
30
|
+
*
|
|
31
|
+
* Holds a *digest* of the binding value rather than the value itself: a
|
|
32
|
+
* callback is only accepted when the caller presents the plaintext that hashes
|
|
33
|
+
* to `bindingHash`, so the record on its own unlocks nothing.
|
|
34
|
+
*/
|
|
35
|
+
export interface PendingState {
|
|
36
|
+
bindingHash: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IssuedState {
|
|
41
|
+
/** Sent to the provider as the OAuth2 `state` parameter. */
|
|
42
|
+
url: string;
|
|
43
|
+
/** Retained so a login that cannot be bound can withdraw its own state. */
|
|
44
|
+
stateToken: string;
|
|
45
|
+
/** Held by the client that started the flow, never by the provider. */
|
|
46
|
+
bindingValue: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ProviderConfig {
|
|
50
|
+
module?: string;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default class OAuth {
|
|
55
|
+
static instance: OAuth | null;
|
|
56
|
+
|
|
57
|
+
providers = new Map<string, ProviderEntry>();
|
|
58
|
+
pendingStates = new Map<string, PendingState>();
|
|
59
|
+
stateTtl = STATE_TTL_MS;
|
|
60
|
+
sessionManager!: SessionManager;
|
|
61
|
+
ticketStore = new TicketStore();
|
|
62
|
+
frontendCallbackUrl?: string;
|
|
63
|
+
|
|
64
|
+
constructor() {
|
|
65
|
+
if (OAuth.instance) return OAuth.instance;
|
|
66
|
+
OAuth.instance = this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async init(): Promise<void> {
|
|
70
|
+
// Self-register so log.oauth works even when @stonyx/oauth is in the
|
|
71
|
+
// consumer's `dependencies` (stonyx loader only merges devDependencies).
|
|
72
|
+
const { logColor = 'magenta', logMethod = 'oauth' } = config.oauth;
|
|
73
|
+
log.defineType(logMethod, logColor);
|
|
74
|
+
|
|
75
|
+
const oauthConfig = config.oauth;
|
|
76
|
+
const { providers, sessionDuration, frontendCallbackUrl } = oauthConfig;
|
|
77
|
+
this.frontendCallbackUrl = frontendCallbackUrl;
|
|
78
|
+
|
|
79
|
+
for (const [name, providerConfig] of Object.entries(providers)) {
|
|
80
|
+
const modulePath = providerConfig.module
|
|
81
|
+
? `${config.rootPath}/${providerConfig.module}`
|
|
82
|
+
: `./providers/${name}.js`;
|
|
83
|
+
const { default: Provider } = await import(modulePath);
|
|
84
|
+
const flow: OAuthFlow = new Provider(providerConfig);
|
|
85
|
+
this.providers.set(name, { flow, tokenManager: new TokenManager(flow) });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.sessionManager = new SessionManager(sessionDuration);
|
|
89
|
+
|
|
90
|
+
await waitForModule('rest-server');
|
|
91
|
+
RestServer.instance.mountRoute(AuthRequest, { name: 'auth', options: this });
|
|
92
|
+
|
|
93
|
+
log.oauth?.('OAuth module initialized');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getProvider(name: string): ProviderEntry {
|
|
97
|
+
const provider = this.providers.get(name);
|
|
98
|
+
if (!provider) throw new Error(`OAuth provider "${name}" is not configured`);
|
|
99
|
+
return provider;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* SHA-256 of a binding value, hex encoded.
|
|
104
|
+
*
|
|
105
|
+
* The pending record stores the digest so that read access to the map does
|
|
106
|
+
* not hand over the value a callback must present.
|
|
107
|
+
*/
|
|
108
|
+
static hash(value: string): string {
|
|
109
|
+
return createHash('sha256').update(value).digest('hex');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Length-independent, content-constant-time comparison of two digests. */
|
|
113
|
+
static digestsMatch(a: string, b: string): boolean {
|
|
114
|
+
if (a.length !== b.length) return false;
|
|
115
|
+
|
|
116
|
+
let difference = 0;
|
|
117
|
+
for (let index = 0; index < a.length; index++) {
|
|
118
|
+
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return difference === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether *any* presented value is the binding value for this record.
|
|
126
|
+
*
|
|
127
|
+
* Every candidate is tried, and the callback is accepted if one matches.
|
|
128
|
+
* Stopping at the first value carrying the cookie's name instead makes a
|
|
129
|
+
* planted cookie a permanent, unauthenticated denial of login: RFC 6265
|
|
130
|
+
* section 5.4 orders the `Cookie` header by path length then creation time,
|
|
131
|
+
* so an attacker with content control on a sibling subdomain sets a
|
|
132
|
+
* same-named cookie once and every subsequent callback for that victim reads
|
|
133
|
+
* theirs, fails the binding check, and burns the state on the way out. The
|
|
134
|
+
* victim cannot recover by retrying.
|
|
135
|
+
*
|
|
136
|
+
* Accepting any match gives an attacker nothing: they would have to present
|
|
137
|
+
* the victim's own binding value, which is the property being checked. And
|
|
138
|
+
* the candidate list is deliberately uncapped — a cap does not bound an
|
|
139
|
+
* attack, it *is* one, reinstating that denial above its own threshold
|
|
140
|
+
* because the planted cookies are the ones that sort first. The work is
|
|
141
|
+
* already bounded by Node's 16 KB header limit.
|
|
142
|
+
*
|
|
143
|
+
* The reduce does not short-circuit, so the work is a function of how many
|
|
144
|
+
* values were presented and not of which one matched.
|
|
145
|
+
*/
|
|
146
|
+
static anyCandidateMatches(candidates: readonly string[], bindingHash: string): boolean {
|
|
147
|
+
return candidates.reduce(
|
|
148
|
+
(matched, candidate) => OAuth.digestsMatch(OAuth.hash(candidate), bindingHash) || matched,
|
|
149
|
+
false,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Starts a flow: an OAuth2 `state` for the provider, and a binding value for
|
|
155
|
+
* the client that asked for it.
|
|
156
|
+
*
|
|
157
|
+
* `state` on its own is replay-window limiting, not the CSRF binding it
|
|
158
|
+
* exists to provide (RFC 6749 section 10.12, RFC 9700): before this, any
|
|
159
|
+
* state issued to any visitor validated for any callback, so an attacker
|
|
160
|
+
* could harvest their own state and code, deliver them to a victim over a
|
|
161
|
+
* plain link, and log the victim into the attacker's account. The binding
|
|
162
|
+
* value is the thing the victim's browser carries and the attacker's does
|
|
163
|
+
* not (#36).
|
|
164
|
+
*/
|
|
165
|
+
getAuthorizationUrl(providerName: string): IssuedState {
|
|
166
|
+
const { flow } = this.getProvider(providerName);
|
|
167
|
+
const stateToken = randomUUID();
|
|
168
|
+
const bindingValue = randomBytes(BINDING_VALUE_BYTES).toString('base64url');
|
|
169
|
+
|
|
170
|
+
this.pendingStates.set(stateToken, {
|
|
171
|
+
bindingHash: OAuth.hash(bindingValue),
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { url: flow.buildAuthorizationUrl(stateToken), stateToken, bindingValue };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Withdraws a state that was issued but could not be handed to a client.
|
|
180
|
+
*
|
|
181
|
+
* Used by the login route when the binding cookie cannot be set: a state the
|
|
182
|
+
* client cannot be bound to is exactly the defect this mechanism exists to
|
|
183
|
+
* prevent, so it must not outlive the request that failed to bind it.
|
|
184
|
+
*/
|
|
185
|
+
discardState(stateToken: string): void {
|
|
186
|
+
this.pendingStates.delete(stateToken);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validates and consumes a pending state, then completes the flow.
|
|
191
|
+
*
|
|
192
|
+
* `bindingValues` is every value the client presented under the binding
|
|
193
|
+
* cookie's name — see `anyCandidateMatches`.
|
|
194
|
+
*/
|
|
195
|
+
async handleCallback(providerName: string, code: string, stateToken: string, bindingValues: readonly string[]) {
|
|
196
|
+
const record = stateToken ? this.pendingStates.get(stateToken) : undefined;
|
|
197
|
+
if (!record) throw new Error('Invalid or missing state token');
|
|
198
|
+
|
|
199
|
+
// Consumed on recognition, before the TTL and binding checks, so every
|
|
200
|
+
// state gets exactly one attempt whatever the outcome. Checking the
|
|
201
|
+
// binding first would leave the record in place on a mismatch and turn
|
|
202
|
+
// this endpoint into a repeatable, unauthenticated oracle against the
|
|
203
|
+
// binding value for the state's full lifetime.
|
|
204
|
+
this.pendingStates.delete(stateToken);
|
|
205
|
+
|
|
206
|
+
if (Date.now() - record.createdAt > this.stateTtl) {
|
|
207
|
+
throw new Error('State token has expired');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// No "absent means skip". An empty candidate list is a rejection, which is
|
|
211
|
+
// what makes an attacker-delivered link fail for a victim who never
|
|
212
|
+
// started the flow and therefore holds no binding cookie.
|
|
213
|
+
const candidates = bindingValues.filter(value => value.length > 0);
|
|
214
|
+
if (candidates.length === 0) throw new Error('Missing state binding value');
|
|
215
|
+
|
|
216
|
+
if (!OAuth.anyCandidateMatches(candidates, record.bindingHash)) {
|
|
217
|
+
throw new Error('State token is not bound to this client');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Everything below burns a live authorization code, so the binding is
|
|
221
|
+
// settled before `exchangeCode` is ever reached.
|
|
222
|
+
const { flow, tokenManager } = this.getProvider(providerName);
|
|
223
|
+
const tokens = await tokenManager.getTokens(code);
|
|
224
|
+
const rawUser = await flow.fetchUserInfo(tokens.accessToken);
|
|
225
|
+
const user = flow.normalizeUser(rawUser);
|
|
226
|
+
await emit('authenticate', user);
|
|
227
|
+
return this.sessionManager.create(user, tokens);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The provider's configured redirect URI, used to decide the cookie's `Secure`. */
|
|
231
|
+
redirectUriFor(providerName: string): string | undefined {
|
|
232
|
+
return this.providers.get(providerName)?.flow.redirectUri;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Mints the value the callback redirect is allowed to put in a URL (#45).
|
|
237
|
+
*
|
|
238
|
+
* The session id never travels in the redirect. What travels is a ticket
|
|
239
|
+
* that is single-use, expires in 60 seconds, and authenticates nothing on
|
|
240
|
+
* its own — `GET /auth` validates against `sessionManager`, which has never
|
|
241
|
+
* heard of it.
|
|
242
|
+
*/
|
|
243
|
+
issueExchangeTicket(session: SessionResult): string {
|
|
244
|
+
return this.ticketStore.issue(session.sessionId, session.expiresAt);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Spends a ticket for the session id it stands for, or `null`. */
|
|
248
|
+
redeemExchangeTicket(ticket: string): RedeemedTicket | null {
|
|
249
|
+
return this.ticketStore.redeem(ticket);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
getSession(sessionId: string) {
|
|
253
|
+
return this.sessionManager.validate(sessionId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
logout(sessionId: string): void {
|
|
257
|
+
this.sessionManager.destroy(sessionId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
export interface OAuthConfig {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
scopes?: string[];
|
|
6
|
+
authorizationUrl: string;
|
|
7
|
+
tokenUrl: string;
|
|
8
|
+
userInfoUrl: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TokenResult {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string | null;
|
|
14
|
+
expiresIn: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
1
17
|
export default class OAuthFlow {
|
|
2
|
-
|
|
18
|
+
clientId: string;
|
|
19
|
+
clientSecret: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
scopes: string[];
|
|
22
|
+
authorizationUrl: string;
|
|
23
|
+
tokenUrl: string;
|
|
24
|
+
userInfoUrl: string;
|
|
25
|
+
|
|
26
|
+
constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }: OAuthConfig) {
|
|
3
27
|
this.clientId = clientId;
|
|
4
28
|
this.clientSecret = clientSecret;
|
|
5
29
|
this.redirectUri = redirectUri;
|
|
@@ -9,7 +33,7 @@ export default class OAuthFlow {
|
|
|
9
33
|
this.userInfoUrl = userInfoUrl;
|
|
10
34
|
}
|
|
11
35
|
|
|
12
|
-
buildAuthorizationUrl(stateToken) {
|
|
36
|
+
buildAuthorizationUrl(stateToken: string): string {
|
|
13
37
|
const params = new URLSearchParams({
|
|
14
38
|
client_id: this.clientId,
|
|
15
39
|
redirect_uri: this.redirectUri,
|
|
@@ -21,7 +45,7 @@ export default class OAuthFlow {
|
|
|
21
45
|
return `${this.authorizationUrl}?${params.toString()}`;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
|
-
async exchangeCode(code) {
|
|
48
|
+
async exchangeCode(code: string): Promise<TokenResult> {
|
|
25
49
|
const response = await fetch(this.tokenUrl, {
|
|
26
50
|
method: 'POST',
|
|
27
51
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -45,7 +69,7 @@ export default class OAuthFlow {
|
|
|
45
69
|
};
|
|
46
70
|
}
|
|
47
71
|
|
|
48
|
-
async refreshAccessToken(refreshToken) {
|
|
72
|
+
async refreshAccessToken(refreshToken: string): Promise<TokenResult> {
|
|
49
73
|
const response = await fetch(this.tokenUrl, {
|
|
50
74
|
method: 'POST',
|
|
51
75
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -68,7 +92,7 @@ export default class OAuthFlow {
|
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
|
71
|
-
async fetchUserInfo(accessToken) {
|
|
95
|
+
async fetchUserInfo(accessToken: string): Promise<unknown> {
|
|
72
96
|
const response = await fetch(this.userInfoUrl, {
|
|
73
97
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
74
98
|
});
|
|
@@ -78,11 +102,11 @@ export default class OAuthFlow {
|
|
|
78
102
|
return response.json();
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
normalizeUser(rawUser) {
|
|
105
|
+
normalizeUser(rawUser: unknown): unknown {
|
|
82
106
|
return { raw: rawUser };
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
async revokeToken(_accessToken) {
|
|
109
|
+
async revokeToken(_accessToken: string): Promise<void> {
|
|
86
110
|
// Optional — providers override if supported
|
|
87
111
|
}
|
|
88
112
|
}
|