@stonyx/oauth 0.1.1-beta.17 → 0.1.1-beta.170

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 (38) hide show
  1. package/README.md +72 -0
  2. package/dist/auth-request.d.ts +97 -0
  3. package/dist/auth-request.js +187 -0
  4. package/dist/main.d.ts +106 -0
  5. package/dist/main.js +177 -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/token-manager.d.ts +15 -0
  13. package/dist/token-manager.js +24 -0
  14. package/package.json +45 -9
  15. package/src/auth-request.ts +271 -0
  16. package/src/main.ts +238 -0
  17. package/src/{oauth-flow.js → oauth-flow.ts} +31 -7
  18. package/src/providers/{discord.js → discord.ts} +29 -3
  19. package/src/{session-manager.js → session-manager.ts} +19 -6
  20. package/src/token-manager.ts +35 -0
  21. package/src/types/node.d.ts +19 -0
  22. package/src/types/stonyx-events.d.ts +4 -0
  23. package/src/types/stonyx-rest-server.d.ts +11 -0
  24. package/src/types/stonyx.d.ts +38 -0
  25. package/.github/workflows/ci.yml +0 -16
  26. package/.github/workflows/publish.yml +0 -51
  27. package/src/auth-request.js +0 -74
  28. package/src/main.js +0 -83
  29. package/src/token-manager.js +0 -26
  30. package/test/config/environment.js +0 -18
  31. package/test/integration/oauth-test.js +0 -149
  32. package/test/sample/providers/mock.js +0 -40
  33. package/test/sample/requests/.gitkeep +0 -0
  34. package/test/unit/oauth-flow-test.js +0 -137
  35. package/test/unit/providers/discord-test.js +0 -115
  36. package/test/unit/session-manager-test.js +0 -85
  37. package/test/unit/state-validation-test.js +0 -118
  38. package/test/unit/token-manager-test.js +0 -76
@@ -0,0 +1,30 @@
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
+ export interface TokenResult {
11
+ accessToken: string;
12
+ refreshToken: string | null;
13
+ expiresIn: number;
14
+ }
15
+ export default class OAuthFlow {
16
+ clientId: string;
17
+ clientSecret: string;
18
+ redirectUri: string;
19
+ scopes: string[];
20
+ authorizationUrl: string;
21
+ tokenUrl: string;
22
+ userInfoUrl: string;
23
+ constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }: OAuthConfig);
24
+ buildAuthorizationUrl(stateToken: string): string;
25
+ exchangeCode(code: string): Promise<TokenResult>;
26
+ refreshAccessToken(refreshToken: string): Promise<TokenResult>;
27
+ fetchUserInfo(accessToken: string): Promise<unknown>;
28
+ normalizeUser(rawUser: unknown): unknown;
29
+ revokeToken(_accessToken: string): Promise<void>;
30
+ }
@@ -0,0 +1,83 @@
1
+ export default class OAuthFlow {
2
+ clientId;
3
+ clientSecret;
4
+ redirectUri;
5
+ scopes;
6
+ authorizationUrl;
7
+ tokenUrl;
8
+ userInfoUrl;
9
+ constructor({ clientId, clientSecret, redirectUri, scopes, authorizationUrl, tokenUrl, userInfoUrl }) {
10
+ this.clientId = clientId;
11
+ this.clientSecret = clientSecret;
12
+ this.redirectUri = redirectUri;
13
+ this.scopes = scopes || [];
14
+ this.authorizationUrl = authorizationUrl;
15
+ this.tokenUrl = tokenUrl;
16
+ this.userInfoUrl = userInfoUrl;
17
+ }
18
+ buildAuthorizationUrl(stateToken) {
19
+ const params = new URLSearchParams({
20
+ client_id: this.clientId,
21
+ redirect_uri: this.redirectUri,
22
+ response_type: 'code',
23
+ scope: this.scopes.join(' '),
24
+ state: stateToken,
25
+ });
26
+ return `${this.authorizationUrl}?${params.toString()}`;
27
+ }
28
+ async exchangeCode(code) {
29
+ const response = await fetch(this.tokenUrl, {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({
33
+ client_id: this.clientId,
34
+ client_secret: this.clientSecret,
35
+ grant_type: 'authorization_code',
36
+ code,
37
+ redirect_uri: this.redirectUri,
38
+ }),
39
+ });
40
+ if (!response.ok)
41
+ throw new Error(`Token exchange failed: ${response.status}`);
42
+ const data = await response.json();
43
+ return {
44
+ accessToken: data.access_token,
45
+ refreshToken: data.refresh_token || null,
46
+ expiresIn: data.expires_in,
47
+ };
48
+ }
49
+ async refreshAccessToken(refreshToken) {
50
+ const response = await fetch(this.tokenUrl, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({
54
+ client_id: this.clientId,
55
+ client_secret: this.clientSecret,
56
+ grant_type: 'refresh_token',
57
+ refresh_token: refreshToken,
58
+ }),
59
+ });
60
+ if (!response.ok)
61
+ throw new Error(`Token refresh failed: ${response.status}`);
62
+ const data = await response.json();
63
+ return {
64
+ accessToken: data.access_token,
65
+ refreshToken: data.refresh_token || refreshToken,
66
+ expiresIn: data.expires_in,
67
+ };
68
+ }
69
+ async fetchUserInfo(accessToken) {
70
+ const response = await fetch(this.userInfoUrl, {
71
+ headers: { Authorization: `Bearer ${accessToken}` },
72
+ });
73
+ if (!response.ok)
74
+ throw new Error(`User info fetch failed: ${response.status}`);
75
+ return response.json();
76
+ }
77
+ normalizeUser(rawUser) {
78
+ return { raw: rawUser };
79
+ }
80
+ async revokeToken(_accessToken) {
81
+ // Optional — providers override if supported
82
+ }
83
+ }
@@ -0,0 +1,30 @@
1
+ import OAuthFlow from '../oauth-flow.js';
2
+ import type { TokenResult } from '../oauth-flow.js';
3
+ interface DiscordProviderConfig {
4
+ clientId: string;
5
+ clientSecret: string;
6
+ redirectUri: string;
7
+ scopes?: string[];
8
+ [key: string]: unknown;
9
+ }
10
+ interface DiscordUser {
11
+ id: string;
12
+ username: string;
13
+ global_name?: string;
14
+ avatar: string | null;
15
+ email?: string | null;
16
+ }
17
+ interface NormalizedDiscordUser {
18
+ id: string;
19
+ username: string;
20
+ displayName: string;
21
+ avatar: string | null;
22
+ email: string | null;
23
+ raw: DiscordUser;
24
+ }
25
+ export default class DiscordProvider extends OAuthFlow {
26
+ constructor(config: DiscordProviderConfig);
27
+ exchangeCode(code: string): Promise<TokenResult>;
28
+ normalizeUser(rawUser: DiscordUser): NormalizedDiscordUser;
29
+ }
30
+ export {};
@@ -0,0 +1,43 @@
1
+ import OAuthFlow from '../oauth-flow.js';
2
+ export default class DiscordProvider extends OAuthFlow {
3
+ constructor(config) {
4
+ super({
5
+ ...config,
6
+ authorizationUrl: 'https://discord.com/oauth2/authorize',
7
+ tokenUrl: 'https://discord.com/api/oauth2/token',
8
+ userInfoUrl: 'https://discord.com/api/users/@me',
9
+ });
10
+ }
11
+ async exchangeCode(code) {
12
+ const response = await fetch(this.tokenUrl, {
13
+ method: 'POST',
14
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
15
+ body: new URLSearchParams({
16
+ client_id: this.clientId,
17
+ client_secret: this.clientSecret,
18
+ grant_type: 'authorization_code',
19
+ code,
20
+ redirect_uri: this.redirectUri,
21
+ }),
22
+ });
23
+ if (!response.ok)
24
+ throw new Error(`Token exchange failed: ${response.status}`);
25
+ const data = await response.json();
26
+ return {
27
+ accessToken: data.access_token,
28
+ refreshToken: data.refresh_token || null,
29
+ expiresIn: data.expires_in,
30
+ };
31
+ }
32
+ normalizeUser(rawUser) {
33
+ const { id, username, global_name, avatar, email } = rawUser;
34
+ return {
35
+ id,
36
+ username,
37
+ displayName: global_name || username,
38
+ avatar: avatar ? `https://cdn.discordapp.com/avatars/${id}/${avatar}.png` : null,
39
+ email: email || null,
40
+ raw: rawUser,
41
+ };
42
+ }
43
+ }
@@ -0,0 +1,20 @@
1
+ interface SessionData {
2
+ user: unknown;
3
+ tokens: unknown;
4
+ expiresAt: number;
5
+ }
6
+ export interface SessionResult {
7
+ sessionId: string;
8
+ user: unknown;
9
+ expiresAt: number;
10
+ }
11
+ export default class SessionManager {
12
+ sessions: Map<string, SessionData>;
13
+ duration: number;
14
+ constructor(duration: number);
15
+ create(user: unknown, tokens: unknown): SessionResult;
16
+ get(sessionId: string): SessionData | null;
17
+ destroy(sessionId: string): void;
18
+ validate(sessionId: string): unknown;
19
+ }
20
+ export {};
@@ -0,0 +1,30 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export default class SessionManager {
3
+ sessions = new Map();
4
+ duration;
5
+ constructor(duration) {
6
+ this.duration = duration;
7
+ }
8
+ create(user, tokens) {
9
+ const sessionId = randomUUID();
10
+ const expiresAt = Date.now() + (this.duration * 1000);
11
+ this.sessions.set(sessionId, { user, tokens, expiresAt });
12
+ return { sessionId, user, expiresAt };
13
+ }
14
+ get(sessionId) {
15
+ return this.sessions.get(sessionId) || null;
16
+ }
17
+ destroy(sessionId) {
18
+ this.sessions.delete(sessionId);
19
+ }
20
+ validate(sessionId) {
21
+ const session = this.get(sessionId);
22
+ if (!session)
23
+ return null;
24
+ if (Date.now() >= session.expiresAt) {
25
+ this.destroy(sessionId);
26
+ return null;
27
+ }
28
+ return session.user;
29
+ }
30
+ }
@@ -0,0 +1,15 @@
1
+ import type OAuthFlow from './oauth-flow.js';
2
+ import type { TokenResult } from './oauth-flow.js';
3
+ export interface TokenData extends TokenResult {
4
+ expiresAt: number;
5
+ }
6
+ export default class TokenManager {
7
+ flow: OAuthFlow;
8
+ constructor(flow: OAuthFlow);
9
+ getTokens(code: string): Promise<TokenData>;
10
+ refresh(refreshToken: string): Promise<TokenData>;
11
+ revoke(accessToken: string): Promise<void>;
12
+ isExpired(tokenData: {
13
+ expiresAt?: number;
14
+ } | null | undefined): boolean;
15
+ }
@@ -0,0 +1,24 @@
1
+ export default class TokenManager {
2
+ flow;
3
+ constructor(flow) {
4
+ this.flow = flow;
5
+ }
6
+ async getTokens(code) {
7
+ const tokens = await this.flow.exchangeCode(code);
8
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
9
+ return tokens;
10
+ }
11
+ async refresh(refreshToken) {
12
+ const tokens = await this.flow.refreshAccessToken(refreshToken);
13
+ tokens.expiresAt = Date.now() + (tokens.expiresIn * 1000);
14
+ return tokens;
15
+ }
16
+ async revoke(accessToken) {
17
+ return this.flow.revokeToken(accessToken);
18
+ }
19
+ isExpired(tokenData) {
20
+ if (!tokenData?.expiresAt)
21
+ return true;
22
+ return Date.now() >= tokenData.expiresAt;
23
+ }
24
+ }
package/package.json CHANGED
@@ -4,40 +4,76 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.1.1-beta.17",
7
+ "version": "0.1.1-beta.170",
8
8
  "description": "OAuth2 authentication module for the Stonyx framework",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/abofs/stonyx-oauth.git"
12
12
  },
13
- "main": "src/main.js",
13
+ "main": "dist/main.js",
14
14
  "type": "module",
15
15
  "exports": {
16
- ".": "./src/main.js"
16
+ ".": {
17
+ "types": "./dist/main.d.ts",
18
+ "default": "./dist/main.js"
19
+ },
20
+ "./oauth-flow": {
21
+ "types": "./dist/oauth-flow.d.ts",
22
+ "default": "./dist/oauth-flow.js"
23
+ },
24
+ "./auth-request": {
25
+ "types": "./dist/auth-request.d.ts",
26
+ "default": "./dist/auth-request.js"
27
+ },
28
+ "./session-manager": {
29
+ "types": "./dist/session-manager.d.ts",
30
+ "default": "./dist/session-manager.js"
31
+ },
32
+ "./token-manager": {
33
+ "types": "./dist/token-manager.d.ts",
34
+ "default": "./dist/token-manager.js"
35
+ },
36
+ "./providers/discord": {
37
+ "types": "./dist/providers/discord.d.ts",
38
+ "default": "./dist/providers/discord.js"
39
+ }
17
40
  },
18
41
  "author": "Stone Costa",
19
42
  "license": "Apache-2.0",
20
43
  "contributors": [
21
44
  "Stone Costa <stone.costa@synamicd.com>"
22
45
  ],
46
+ "files": [
47
+ "dist",
48
+ "src",
49
+ "config",
50
+ "README.md"
51
+ ],
23
52
  "publishConfig": {
24
53
  "access": "public",
25
54
  "provenance": true
26
55
  },
27
56
  "dependencies": {
28
- "stonyx": "0.2.3-beta.6",
29
- "@stonyx/events": "0.1.1-beta.7"
57
+ "@stonyx/events": "0.1.1-beta.54",
58
+ "stonyx": "0.2.3-beta.83"
30
59
  },
31
60
  "peerDependencies": {
32
61
  "@stonyx/rest-server": ">=0.2.1-beta.11"
33
62
  },
34
63
  "devDependencies": {
35
- "@stonyx/rest-server": "0.2.1-beta.19",
36
- "@stonyx/utils": "0.2.3-beta.5",
64
+ "@stonyx/rest-server": "0.2.1-beta.101",
65
+ "@stonyx/utils": "0.2.3-beta.26",
66
+ "@stonyx/logs": "1.0.1-beta.20",
67
+ "@types/qunit": "^2.19.13",
68
+ "@types/sinon": "^21.0.1",
37
69
  "qunit": "^2.24.1",
38
- "sinon": "^21.0.0"
70
+ "sinon": "^21.0.0",
71
+ "tsx": "^4.21.0",
72
+ "typescript": "^5.8.3"
39
73
  },
40
74
  "scripts": {
41
- "test": "stonyx test"
75
+ "build": "tsc",
76
+ "build:test": "tsc -p tsconfig.test.json",
77
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
42
78
  }
43
79
  }
@@ -0,0 +1,271 @@
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
+ logout(sessionId: string): void;
44
+ }
45
+
46
+ export interface CookieOptions {
47
+ httpOnly: boolean;
48
+ sameSite: string;
49
+ path: string;
50
+ secure: boolean;
51
+ maxAge?: number;
52
+ }
53
+
54
+ /**
55
+ * The response object express hangs off the request.
56
+ *
57
+ * `@stonyx/rest-server` hands handlers `(req, state)` only, and `state.pipe.headers`
58
+ * is unreachable once `state.redirect` is set (`request.ts` returns on the
59
+ * redirect first), so setting a cookie means reaching for `req.res`.
60
+ *
61
+ * This is a deliberate, sanctioned interim reach-around, not an accident:
62
+ * `abofs/stonyx-rest-server#45` is the reopened successor issue that adds a
63
+ * first-class header/cookie affordance to migrate onto, and it is sequenced
64
+ * after this fix. `setBindingCookie` fails closed if the affordance is not
65
+ * there, which is what contains the dependency.
66
+ */
67
+ interface ResponseLike {
68
+ cookie(name: string, value: string, options: CookieOptions): unknown;
69
+ clearCookie(name: string, options: Omit<CookieOptions, 'maxAge'>): unknown;
70
+ }
71
+
72
+ interface RouteRequest {
73
+ headers: Record<string, string | undefined>;
74
+ params: Record<string, string>;
75
+ query: Record<string, string>;
76
+ res?: ResponseLike;
77
+ }
78
+
79
+ interface RouteState {
80
+ redirect?: string;
81
+ }
82
+
83
+ export default class AuthRequest extends Request {
84
+ oauth: OAuthInstance;
85
+
86
+ constructor(oauth: OAuthInstance) {
87
+ super();
88
+ this.oauth = oauth;
89
+ }
90
+
91
+ handlers = {
92
+ get: {
93
+ '/': ({ headers }: RouteRequest) => {
94
+ const sessionId = headers['session-id'];
95
+ if (!sessionId) return 401;
96
+
97
+ const user = this.oauth.getSession(sessionId);
98
+ if (!user) return 401;
99
+
100
+ return user;
101
+ },
102
+
103
+ '/login/:provider': (req: RouteRequest, state: RouteState) => {
104
+ const { provider: providerName } = req.params;
105
+
106
+ let authorization: AuthorizationRequest;
107
+ try {
108
+ authorization = this.oauth.getAuthorizationUrl(providerName);
109
+ } catch {
110
+ return 404;
111
+ }
112
+
113
+ // Fail closed. A state we cannot bind to this client is exactly the
114
+ // defect this mechanism exists to prevent, so it is withdrawn rather
115
+ // than issued unbindable.
116
+ if (!this.setBindingCookie(req, providerName, authorization.bindingValue)) {
117
+ this.oauth.discardState(authorization.stateToken);
118
+ return 500;
119
+ }
120
+
121
+ state.redirect = authorization.url;
122
+ },
123
+
124
+ '/callback/:provider': async (req: RouteRequest, state: RouteState) => {
125
+ const { provider: providerName } = req.params;
126
+ const { code, state: stateToken, error } = req.query;
127
+
128
+ if (error) {
129
+ if (this.oauth.frontendCallbackUrl) {
130
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=${encodeURIComponent(error)}`;
131
+ return;
132
+ }
133
+ return 400;
134
+ }
135
+
136
+ if (!code) return 400;
137
+
138
+ try {
139
+ const session = await this.oauth.handleCallback(
140
+ providerName,
141
+ code,
142
+ stateToken,
143
+ this.readBindingCookies(req),
144
+ );
145
+
146
+ // Cleared only here, on the success path, which is the only path that
147
+ // is certain to have consumed a state belonging to *this* client.
148
+ //
149
+ // Clearing on failure instead looks harmless and is not: `code` is
150
+ // attacker-supplied and unvalidated, so a bare `?code=1` — no
151
+ // knowledge of anyone's state — would delete the binding cookie of a
152
+ // client still sitting on the provider's consent screen, leaving
153
+ // their pending state untouched so nothing is detectable
154
+ // server-side, and their real callback then fails.
155
+ this.clearBindingCookie(req, providerName);
156
+
157
+ if (this.oauth.frontendCallbackUrl) {
158
+ const params = new URLSearchParams({
159
+ sessionId: session.sessionId,
160
+ expiresAt: String(session.expiresAt),
161
+ });
162
+ state.redirect = `${this.oauth.frontendCallbackUrl}?${params}`;
163
+ return;
164
+ }
165
+
166
+ return session;
167
+ } catch {
168
+ if (this.oauth.frontendCallbackUrl) {
169
+ state.redirect = `${this.oauth.frontendCallbackUrl}?error=auth_failed`;
170
+ return;
171
+ }
172
+ return 500;
173
+ }
174
+ },
175
+
176
+ '/logout': ({ headers }: RouteRequest) => {
177
+ const sessionId = headers['session-id'];
178
+ if (sessionId) this.oauth.logout(sessionId);
179
+ },
180
+ }
181
+ };
182
+
183
+ /**
184
+ * Whether the binding cookie is issued with `Secure`.
185
+ *
186
+ * Derived from the scheme of the provider's configured `redirectUri`, which
187
+ * is the deployment's own statement of the origin this cookie has to survive
188
+ * a round trip to.
189
+ *
190
+ * Not `req.secure`: express derives that from the socket unless `trust proxy`
191
+ * is on, and `@stonyx/rest-server` leaves it off by default, so in the
192
+ * standard production topology — TLS terminated at a proxy, plaintext to the
193
+ * origin — `req.secure` is `false` on every request to an HTTPS site and the
194
+ * cookie would ship without `Secure` while the deployment looks correct. Not
195
+ * the `Host` header either: that is attacker-controllable on any non-browser
196
+ * client. And not hardcoded `true`, which breaks plaintext local development.
197
+ *
198
+ * An unparseable or absent redirect URI fails secure.
199
+ */
200
+ isSecureContext(providerName: string): boolean {
201
+ const redirectUri = this.oauth.redirectUriFor(providerName);
202
+ if (!redirectUri) return true;
203
+
204
+ try {
205
+ return new URL(redirectUri).protocol !== 'http:';
206
+ } catch {
207
+ return true;
208
+ }
209
+ }
210
+
211
+ cookieOptions(providerName: string): Omit<CookieOptions, 'maxAge'> {
212
+ return {
213
+ httpOnly: true,
214
+ sameSite: STATE_COOKIE_SAME_SITE,
215
+ path: STATE_COOKIE_PATH,
216
+ secure: this.isSecureContext(providerName),
217
+ };
218
+ }
219
+
220
+ setBindingCookie(req: RouteRequest, providerName: string, bindingValue: string): boolean {
221
+ const { res } = req;
222
+
223
+ if (typeof res?.cookie !== 'function') {
224
+ log.error('OAuth: unable to set the state binding cookie; login rejected');
225
+ return false;
226
+ }
227
+
228
+ res.cookie(STATE_COOKIE_NAME, bindingValue, {
229
+ ...this.cookieOptions(providerName),
230
+ maxAge: this.oauth.stateTtl,
231
+ });
232
+
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * Every value the client presented under the binding cookie's name.
238
+ *
239
+ * Not the first one, and not capped — see `OAuth.anyCandidateMatches` for why
240
+ * either would hand an attacker a permanent, unauthenticated denial of login
241
+ * for any victim they can plant a same-named cookie on.
242
+ */
243
+ readBindingCookies(req: RouteRequest): string[] {
244
+ const header = req.headers.cookie;
245
+ if (!header) return [];
246
+
247
+ const values: string[] = [];
248
+
249
+ for (const part of header.split(';')) {
250
+ const separator = part.indexOf('=');
251
+ if (separator === -1) continue;
252
+ if (part.slice(0, separator).trim() !== STATE_COOKIE_NAME) continue;
253
+
254
+ // Not decoded. The binding value is base64url, whose alphabet
255
+ // `encodeURIComponent` never escapes, so decoding buys nothing — and
256
+ // `decodeURIComponent` throws `URIError` on malformed input, which any
257
+ // unauthenticated caller can supply, turning the first line of the
258
+ // callback into a 500.
259
+ values.push(part.slice(separator + 1).trim());
260
+ }
261
+
262
+ return values;
263
+ }
264
+
265
+ clearBindingCookie(req: RouteRequest, providerName: string): void {
266
+ const { res } = req;
267
+ if (typeof res?.clearCookie !== 'function') return;
268
+
269
+ res.clearCookie(STATE_COOKIE_NAME, this.cookieOptions(providerName));
270
+ }
271
+ }