@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.
Files changed (41) hide show
  1. package/README.md +193 -2
  2. package/dist/auth-request.d.ts +147 -0
  3. package/dist/auth-request.js +247 -0
  4. package/dist/main.d.ts +121 -0
  5. package/dist/main.js +194 -0
  6. package/dist/oauth-flow.d.ts +30 -0
  7. package/dist/oauth-flow.js +83 -0
  8. package/dist/providers/discord.d.ts +30 -0
  9. package/dist/providers/discord.js +43 -0
  10. package/dist/session-manager.d.ts +20 -0
  11. package/dist/session-manager.js +30 -0
  12. package/dist/ticket-store.d.ts +134 -0
  13. package/dist/ticket-store.js +140 -0
  14. package/dist/token-manager.d.ts +15 -0
  15. package/dist/token-manager.js +24 -0
  16. package/package.json +45 -8
  17. package/src/auth-request.ts +348 -0
  18. package/src/main.ts +259 -0
  19. package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
  20. package/src/providers/{discord.js → discord.ts} +29 -3
  21. package/src/{session-manager.js → session-manager.ts} +19 -6
  22. package/src/ticket-store.ts +158 -0
  23. package/src/token-manager.ts +35 -0
  24. package/src/types/node.d.ts +19 -0
  25. package/src/types/stonyx-events.d.ts +4 -0
  26. package/src/types/stonyx-rest-server.d.ts +11 -0
  27. package/src/types/stonyx.d.ts +38 -0
  28. package/.github/workflows/ci.yml +0 -16
  29. package/.github/workflows/publish.yml +0 -51
  30. package/src/auth-request.js +0 -74
  31. package/src/main.js +0 -79
  32. package/src/token-manager.js +0 -26
  33. package/test/config/environment.js +0 -18
  34. package/test/integration/oauth-test.js +0 -149
  35. package/test/sample/providers/mock.js +0 -40
  36. package/test/sample/requests/.gitkeep +0 -0
  37. package/test/unit/oauth-flow-test.js +0 -137
  38. package/test/unit/providers/discord-test.js +0 -115
  39. package/test/unit/session-manager-test.js +0 -85
  40. package/test/unit/state-validation-test.js +0 -118
  41. 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
- constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }) {
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
  }