startgg-oauth2-full 0.1.0
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/.github/ISSUE_TEMPLATE/bug_report.md +18 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +13 -0
- package/.github/pull_request_template.md +30 -0
- package/.github/workflows/ci.yml +23 -0
- package/CONTRIBUTING.md +36 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/STARTGG_OAUTH_SETUP.md +41 -0
- package/__tests__/authorize-url.test.ts +62 -0
- package/__tests__/bearer-token.test.ts +21 -0
- package/__tests__/handler.test.ts +111 -0
- package/__tests__/pkce.test.ts +17 -0
- package/examples/browser/README.md +20 -0
- package/examples/browser/index.html +55 -0
- package/examples/browser/package.json +17 -0
- package/examples/browser/src/main.ts +105 -0
- package/examples/browser/tsconfig.json +11 -0
- package/examples/browser/vite.config.ts +8 -0
- package/examples/discordjs/.env.example +9 -0
- package/examples/discordjs/README.md +36 -0
- package/examples/discordjs/package.json +23 -0
- package/examples/discordjs/src/bot.ts +202 -0
- package/examples/discordjs/tsconfig.json +12 -0
- package/examples/nextjs/.env.example +7 -0
- package/examples/nextjs/README.md +33 -0
- package/examples/nextjs/app/api/startgg/auth-url/route.ts +36 -0
- package/examples/nextjs/app/api/startgg/callback/route.ts +55 -0
- package/examples/nextjs/app/globals.css +48 -0
- package/examples/nextjs/app/layout.tsx +15 -0
- package/examples/nextjs/app/page.tsx +93 -0
- package/examples/nextjs/lib/pendingStore.ts +37 -0
- package/examples/nextjs/lib/startgg.ts +28 -0
- package/examples/nextjs/next-env.d.ts +5 -0
- package/examples/nextjs/next.config.mjs +6 -0
- package/examples/nextjs/package.json +25 -0
- package/examples/nextjs/tsconfig.json +21 -0
- package/examples/node/.env.example +4 -0
- package/examples/node/README.md +27 -0
- package/examples/node/package.json +17 -0
- package/examples/node/src/index.ts +57 -0
- package/examples/node/src/server.ts +120 -0
- package/examples/node/tsconfig.json +12 -0
- package/examples/vite/README.md +22 -0
- package/examples/vite/index.html +41 -0
- package/examples/vite/package.json +18 -0
- package/examples/vite/src/main.ts +38 -0
- package/examples/vite/tsconfig.json +11 -0
- package/examples/vite/vite.config.ts +8 -0
- package/jest.config.ts +15 -0
- package/jest.setup.ts +29 -0
- package/package.json +24 -0
- package/src/auth/StartGGOAuth2.ts +378 -0
- package/tsconfig.json +25 -0
package/jest.setup.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { webcrypto as nodeWebcrypto } from 'node:crypto';
|
|
2
|
+
import { TextEncoder, TextDecoder } from 'node:util';
|
|
3
|
+
|
|
4
|
+
// WebCrypto
|
|
5
|
+
// @ts-ignore
|
|
6
|
+
if (!global.crypto) global.crypto = nodeWebcrypto as unknown as Crypto;
|
|
7
|
+
|
|
8
|
+
// TextEncoder/Decoder
|
|
9
|
+
// @ts-ignore
|
|
10
|
+
if (!global.TextEncoder) global.TextEncoder = TextEncoder as any;
|
|
11
|
+
// @ts-ignore
|
|
12
|
+
if (!global.TextDecoder) global.TextDecoder = TextDecoder as any;
|
|
13
|
+
|
|
14
|
+
// Response/Headers/Request (Node)
|
|
15
|
+
// @ts-ignore
|
|
16
|
+
if (typeof Response === 'undefined') {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const { Response, Headers, Request } = require('node-fetch');
|
|
19
|
+
// @ts-ignore
|
|
20
|
+
global.Response = Response;
|
|
21
|
+
// @ts-ignore
|
|
22
|
+
global.Headers = Headers;
|
|
23
|
+
// @ts-ignore
|
|
24
|
+
global.Request = Request;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Default fetch mock (tests override per suite)
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
if (!global.fetch) global.fetch = jest.fn();
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-full",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev:browser": "serve examples/browser -p 5174",
|
|
8
|
+
"dev:node": "tsx examples/node/index.ts",
|
|
9
|
+
"dev:node:server": "tsx examples/node/server.ts",
|
|
10
|
+
"test": "jest --runInBand",
|
|
11
|
+
"build": "tsc -p tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/jest": "^29.5.12",
|
|
15
|
+
"@types/node": "^22.7.5",
|
|
16
|
+
"jest": "^29.7.0",
|
|
17
|
+
"node-fetch": "^3.3.2",
|
|
18
|
+
"serve": "^14.2.1",
|
|
19
|
+
"ts-jest": "^29.2.5",
|
|
20
|
+
"ts-node": "10.9.2",
|
|
21
|
+
"tsx": "^4.19.0",
|
|
22
|
+
"typescript": "^5.6.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Full RFC-compliant implementation
|
|
2
|
+
// of OAuth2 Authorization Code Flow with PKCE (RFC 6749, RFC 7636)
|
|
3
|
+
// for Start.gg API (https://start.gg/docs/oauth2).
|
|
4
|
+
// Happy to accept PRs for improvements or fixes!
|
|
5
|
+
// (c) 2025 0xabadbabe (jet'aime), Inc. (MIT License)
|
|
6
|
+
// Happy coding! <3
|
|
7
|
+
|
|
8
|
+
export enum StartGGScope {
|
|
9
|
+
USER_IDENTITY = 'user.identity',
|
|
10
|
+
USER_EMAIL = 'user.email',
|
|
11
|
+
TOURNAMENT_MANAGER = 'tournament.manager',
|
|
12
|
+
TOURNAMENT_REPORTER = 'tournament.reporter',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IOAuth2HandlerWithPKCE {
|
|
16
|
+
exchangeToken(
|
|
17
|
+
code: string,
|
|
18
|
+
codeVerifier: string,
|
|
19
|
+
expectedScopes: StartGGScope[]
|
|
20
|
+
): Promise<OAuth2TokenResponse>;
|
|
21
|
+
|
|
22
|
+
refreshToken(
|
|
23
|
+
refreshToken: string,
|
|
24
|
+
originalScopes: StartGGScope[]
|
|
25
|
+
): Promise<OAuth2TokenResponse>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class OAuth2Error extends Error {
|
|
29
|
+
public readonly code?: string;
|
|
30
|
+
public readonly details?: unknown;
|
|
31
|
+
constructor(message: string, code?: string, details?: unknown) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'OAuth2Error';
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.details = details;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OAuth2TokenRequestAuthCode {
|
|
40
|
+
grant_type: 'authorization_code';
|
|
41
|
+
code: string;
|
|
42
|
+
redirect_uri: string;
|
|
43
|
+
code_verifier: string;
|
|
44
|
+
client_id: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OAuth2TokenRequestRefresh {
|
|
48
|
+
grant_type: 'refresh_token';
|
|
49
|
+
refresh_token: string;
|
|
50
|
+
client_id: string;
|
|
51
|
+
scope?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface OAuth2TokenResponse {
|
|
55
|
+
access_token: string;
|
|
56
|
+
token_type: string; // runtime-validated to Bearer
|
|
57
|
+
expires_in?: number; // seconds
|
|
58
|
+
refresh_token?: string;
|
|
59
|
+
scope?: string; // optional space-delimited
|
|
60
|
+
[k: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ScopeValidationError extends OAuth2Error {
|
|
64
|
+
constructor(
|
|
65
|
+
message: string,
|
|
66
|
+
public readonly requestedScopes: string[],
|
|
67
|
+
public readonly grantedScopes?: string[]
|
|
68
|
+
) {
|
|
69
|
+
super(message, 'SCOPE_VALIDATION_FAILED');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** WebCrypto subtle (browser + Node >=18). */
|
|
74
|
+
async function getSubtleCrypto(): Promise<SubtleCrypto> {
|
|
75
|
+
const g = globalThis as any;
|
|
76
|
+
if (g.crypto?.subtle) return g.crypto.subtle as SubtleCrypto;
|
|
77
|
+
throw new OAuth2Error('WebCrypto subtle not available; required for PKCE S256', 'CRYPTO_UNAVAILABLE');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Base64 (Buffer if available, else btoa path). */
|
|
81
|
+
function base64Encode(bytes: Uint8Array): string {
|
|
82
|
+
const g = globalThis as any;
|
|
83
|
+
if (typeof g.Buffer?.from === 'function') return g.Buffer.from(bytes).toString('base64');
|
|
84
|
+
let binary = '';
|
|
85
|
+
const chunk = 0x8000;
|
|
86
|
+
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
87
|
+
if (typeof g.btoa !== 'function') throw new OAuth2Error('btoa not available for base64 encoding', 'B64_UNAVAILABLE');
|
|
88
|
+
return g.btoa(binary);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** URL-safe Base64 (no padding). */
|
|
92
|
+
function base64Url(bytes: ArrayBuffer | Uint8Array): string {
|
|
93
|
+
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
94
|
+
return base64Encode(u8).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Random bytes using WebCrypto. */
|
|
98
|
+
function getRandomBytes(length: number): Uint8Array {
|
|
99
|
+
const g = globalThis as any;
|
|
100
|
+
if (!g.crypto?.getRandomValues) throw new OAuth2Error('crypto.getRandomValues unavailable', 'RNG_UNAVAILABLE');
|
|
101
|
+
const bytes = new Uint8Array(length);
|
|
102
|
+
g.crypto.getRandomValues(bytes);
|
|
103
|
+
return bytes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** x-www-form-urlencoded body. */
|
|
107
|
+
function formBody(params: Record<string, string>): string {
|
|
108
|
+
const sp = new URLSearchParams();
|
|
109
|
+
for (const [k, v] of Object.entries(params)) sp.set(k, v);
|
|
110
|
+
return sp.toString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse non-JSON error bodies safely. */
|
|
114
|
+
async function parseJsonSafe(res: Response): Promise<unknown> {
|
|
115
|
+
const txt = await res.text();
|
|
116
|
+
try { return JSON.parse(txt); } catch { return { raw: txt }; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Validate token response + Bearer type. */
|
|
120
|
+
function validateTokenResponse(tr: OAuth2TokenResponse): void {
|
|
121
|
+
if (!tr || typeof tr !== 'object') throw new OAuth2Error('Invalid token response shape', 'INVALID_TOKEN_RESPONSE', tr);
|
|
122
|
+
if (!tr.access_token) throw new OAuth2Error('Missing access_token', 'INVALID_TOKEN_RESPONSE', tr);
|
|
123
|
+
if (!tr.token_type || tr.token_type.toLowerCase() !== 'bearer') {
|
|
124
|
+
throw new OAuth2Error('Unsupported token_type', 'UNSUPPORTED_TOKEN_TYPE', tr);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** If scope omitted, assume unchanged (RFC 6749). */
|
|
129
|
+
function validateScopesOrAssumePrevious(responseScope: string | undefined, requiredScopes: string[]): void {
|
|
130
|
+
if (!requiredScopes.length) return;
|
|
131
|
+
if (responseScope == null) return;
|
|
132
|
+
const granted = new Set(responseScope.split(/\s+/).filter(Boolean));
|
|
133
|
+
const missing = requiredScopes.filter(s => !granted.has(s));
|
|
134
|
+
if (missing.length > 0) {
|
|
135
|
+
throw new ScopeValidationError(
|
|
136
|
+
`Missing required scopes: ${missing.join(', ')}`,
|
|
137
|
+
requiredScopes,
|
|
138
|
+
Array.from(granted)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Fetch with timeout to avoid hangs. */
|
|
144
|
+
async function fetchWithTimeout(input: RequestInfo | URL, init: RequestInit & { timeoutMs?: number } = {}) {
|
|
145
|
+
const { timeoutMs = 15000, ...rest } = init;
|
|
146
|
+
const ac = new AbortController();
|
|
147
|
+
const id = setTimeout(() => ac.abort(), timeoutMs);
|
|
148
|
+
try {
|
|
149
|
+
return await fetch(input, { ...rest, signal: ac.signal });
|
|
150
|
+
} finally {
|
|
151
|
+
clearTimeout(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -------- PKCE (RFC 7636) --------
|
|
156
|
+
|
|
157
|
+
const PKCE_VERIFIER_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
158
|
+
|
|
159
|
+
export function generateCodeVerifier(length = 64): string {
|
|
160
|
+
const len = Math.min(Math.max(length, 43), 128);
|
|
161
|
+
const alphabet = PKCE_VERIFIER_CHARS;
|
|
162
|
+
const alphabetLength = alphabet.length;
|
|
163
|
+
const maxValue = Math.floor(256 / alphabetLength) * alphabetLength;
|
|
164
|
+
const result: string[] = [];
|
|
165
|
+
|
|
166
|
+
while (result.length < len) {
|
|
167
|
+
const randomBytes = getRandomBytes(len - result.length);
|
|
168
|
+
for (const byte of randomBytes) {
|
|
169
|
+
if (byte >= maxValue) continue; // Skip to avoid modulo bias.
|
|
170
|
+
result.push(alphabet.charAt(byte % alphabetLength));
|
|
171
|
+
if (result.length === len) break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result.join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** S256 challenge for a verifier. */
|
|
179
|
+
export async function computeCodeChallengeS256(codeVerifier: string): Promise<string> {
|
|
180
|
+
const enc = new TextEncoder();
|
|
181
|
+
const subtle = await getSubtleCrypto();
|
|
182
|
+
const hash = await subtle.digest('SHA-256', enc.encode(codeVerifier));
|
|
183
|
+
return base64Url(hash);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -------- Authorize URL (RFC 6749 §4.1) --------
|
|
187
|
+
|
|
188
|
+
export type AuthorizeUrlOptions = {
|
|
189
|
+
scopes: (StartGGScope | string)[];
|
|
190
|
+
state?: string;
|
|
191
|
+
prompt?: string;
|
|
192
|
+
codeVerifier?: string;
|
|
193
|
+
codeChallenge?: string;
|
|
194
|
+
extras?: Record<string, string | number | boolean | undefined>;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export type BuiltAuthorizeUrl = {
|
|
198
|
+
url: string;
|
|
199
|
+
codeVerifier: string;
|
|
200
|
+
codeChallenge: string;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Build an authorization URL with PKCE (S256). */
|
|
204
|
+
export async function buildAuthorizeUrl(
|
|
205
|
+
cfg: { clientId: string; authEndpoint: string; redirectUri: string },
|
|
206
|
+
opts: AuthorizeUrlOptions
|
|
207
|
+
): Promise<BuiltAuthorizeUrl> {
|
|
208
|
+
let codeVerifier: string;
|
|
209
|
+
let codeChallenge: string;
|
|
210
|
+
|
|
211
|
+
if (opts.codeVerifier && opts.codeChallenge) {
|
|
212
|
+
const expectedChallenge = await computeCodeChallengeS256(opts.codeVerifier);
|
|
213
|
+
if (expectedChallenge !== opts.codeChallenge) {
|
|
214
|
+
throw new OAuth2Error('Provided codeChallenge does not match codeVerifier', 'INVALID_PKCE_PAIR');
|
|
215
|
+
}
|
|
216
|
+
codeVerifier = opts.codeVerifier;
|
|
217
|
+
codeChallenge = opts.codeChallenge;
|
|
218
|
+
} else if (opts.codeVerifier) {
|
|
219
|
+
codeVerifier = opts.codeVerifier;
|
|
220
|
+
codeChallenge = await computeCodeChallengeS256(codeVerifier);
|
|
221
|
+
} else if (opts.codeChallenge) {
|
|
222
|
+
throw new OAuth2Error('codeVerifier is required when providing codeChallenge', 'INVALID_PKCE_PAIR');
|
|
223
|
+
} else {
|
|
224
|
+
codeVerifier = generateCodeVerifier();
|
|
225
|
+
codeChallenge = await computeCodeChallengeS256(codeVerifier);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const u = new URL(cfg.authEndpoint);
|
|
229
|
+
const params: Record<string, string> = {
|
|
230
|
+
response_type: 'code',
|
|
231
|
+
client_id: cfg.clientId,
|
|
232
|
+
redirect_uri: cfg.redirectUri,
|
|
233
|
+
scope: opts.scopes.map(String).join(' '),
|
|
234
|
+
code_challenge: codeChallenge,
|
|
235
|
+
code_challenge_method: 'S256',
|
|
236
|
+
};
|
|
237
|
+
if (opts.state) params.state = opts.state;
|
|
238
|
+
if (opts.prompt) params.prompt = opts.prompt;
|
|
239
|
+
if (opts.extras) {
|
|
240
|
+
for (const [k, v] of Object.entries(opts.extras)) if (v != null) params[k] = String(v);
|
|
241
|
+
}
|
|
242
|
+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
|
|
243
|
+
|
|
244
|
+
return { url: u.toString(), codeVerifier, codeChallenge };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -------- Bearer Token Helper (RFC 6750) --------
|
|
248
|
+
|
|
249
|
+
export class BearerToken {
|
|
250
|
+
readonly accessToken: string;
|
|
251
|
+
readonly tokenType: 'Bearer';
|
|
252
|
+
readonly refreshToken?: string;
|
|
253
|
+
readonly expiresAt?: number; // epoch ms
|
|
254
|
+
|
|
255
|
+
private constructor(init: { accessToken: string; tokenType: 'Bearer'; refreshToken?: string; expiresAt?: number; }) {
|
|
256
|
+
this.accessToken = init.accessToken;
|
|
257
|
+
this.tokenType = init.tokenType;
|
|
258
|
+
this.refreshToken = init.refreshToken;
|
|
259
|
+
this.expiresAt = init.expiresAt;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
static fromOAuthResponse(res: OAuth2TokenResponse, nowMs: number = Date.now(), skewSeconds = 60): BearerToken {
|
|
263
|
+
validateTokenResponse(res);
|
|
264
|
+
const expiresAt =
|
|
265
|
+
typeof res.expires_in === 'number'
|
|
266
|
+
? nowMs + Math.max(0, (res.expires_in - Math.max(0, skewSeconds)) * 1000)
|
|
267
|
+
: undefined;
|
|
268
|
+
|
|
269
|
+
return new BearerToken({
|
|
270
|
+
accessToken: res.access_token,
|
|
271
|
+
tokenType: 'Bearer',
|
|
272
|
+
refreshToken: res.refresh_token,
|
|
273
|
+
expiresAt,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
isExpired(nowMs: number = Date.now()): boolean {
|
|
278
|
+
if (this.expiresAt == null) return false; // unknown → treat as non-expiring
|
|
279
|
+
return nowMs >= this.expiresAt;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
willExpireWithin(seconds: number, nowMs: number = Date.now()): boolean {
|
|
283
|
+
if (this.expiresAt == null) return false;
|
|
284
|
+
return this.expiresAt - nowMs <= seconds * 1000;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
toAuthHeader(): Record<string, string> {
|
|
288
|
+
return { Authorization: `Bearer ${this.accessToken}` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
assertUsable(nowMs: number = Date.now()): void {
|
|
292
|
+
if (this.isExpired(nowMs)) throw new OAuth2Error('Access token expired', 'TOKEN_EXPIRED');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// -------- Handler (RFC 6749 §4.1.3, §6) --------
|
|
297
|
+
|
|
298
|
+
export class StartGGOAuth2Handler implements IOAuth2HandlerWithPKCE {
|
|
299
|
+
constructor(
|
|
300
|
+
private readonly config: {
|
|
301
|
+
clientId: string;
|
|
302
|
+
redirectUri: string;
|
|
303
|
+
authEndpoint: string; // used by buildAuthorizeUrl
|
|
304
|
+
tokenEndpoint: string;
|
|
305
|
+
fetchTimeoutMs?: number;
|
|
306
|
+
}
|
|
307
|
+
) {}
|
|
308
|
+
|
|
309
|
+
/** Exchange authorization code for tokens (PKCE). */
|
|
310
|
+
async exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse> {
|
|
311
|
+
const tokenRequest: OAuth2TokenRequestAuthCode = {
|
|
312
|
+
grant_type: 'authorization_code',
|
|
313
|
+
code,
|
|
314
|
+
redirect_uri: this.config.redirectUri,
|
|
315
|
+
code_verifier: codeVerifier,
|
|
316
|
+
client_id: this.config.clientId,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const res = await fetchWithTimeout(this.config.tokenEndpoint, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
322
|
+
body: formBody(tokenRequest as unknown as Record<string, string>),
|
|
323
|
+
timeoutMs: this.config.fetchTimeoutMs ?? 15000,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
const details = await parseJsonSafe(res);
|
|
328
|
+
throw new OAuth2Error('Token exchange failed', 'TOKEN_EXCHANGE_FAILED', details);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const tokenResponse = (await res.json()) as OAuth2TokenResponse;
|
|
332
|
+
validateTokenResponse(tokenResponse);
|
|
333
|
+
validateScopesOrAssumePrevious(tokenResponse.scope, expectedScopes.map(String));
|
|
334
|
+
|
|
335
|
+
return tokenResponse;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Refresh access token; preserve prior refresh token if server omits rotation. */
|
|
339
|
+
async refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse> {
|
|
340
|
+
const refreshRequest: OAuth2TokenRequestRefresh = {
|
|
341
|
+
grant_type: 'refresh_token',
|
|
342
|
+
refresh_token: refreshToken,
|
|
343
|
+
client_id: this.config.clientId,
|
|
344
|
+
scope: originalScopes.length ? originalScopes.join(' ') : undefined,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const res = await fetchWithTimeout(this.config.tokenEndpoint, {
|
|
348
|
+
method: 'POST',
|
|
349
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
|
|
350
|
+
body: formBody(Object.fromEntries(Object.entries(refreshRequest).filter(([, v]) => v != null)) as Record<string, string>),
|
|
351
|
+
timeoutMs: this.config.fetchTimeoutMs ?? 15000,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
const details = await parseJsonSafe(res);
|
|
356
|
+
throw new OAuth2Error('Token refresh failed', 'TOKEN_REFRESH_FAILED', details);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const tokenResponse = (await res.json()) as OAuth2TokenResponse;
|
|
360
|
+
validateTokenResponse(tokenResponse);
|
|
361
|
+
validateScopesOrAssumePrevious(tokenResponse.scope, originalScopes.map(String));
|
|
362
|
+
|
|
363
|
+
if (!tokenResponse.refresh_token) tokenResponse.refresh_token = refreshToken;
|
|
364
|
+
|
|
365
|
+
return tokenResponse;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Factory */
|
|
370
|
+
export function createStartGGAuth2Handler(params: {
|
|
371
|
+
clientId: string;
|
|
372
|
+
redirectUri: string;
|
|
373
|
+
authEndpoint: string;
|
|
374
|
+
tokenEndpoint: string;
|
|
375
|
+
fetchTimeoutMs?: number;
|
|
376
|
+
}) {
|
|
377
|
+
return new StartGGOAuth2Handler(params);
|
|
378
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2022",
|
|
8
|
+
"DOM"
|
|
9
|
+
],
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"outDir": "dist",
|
|
14
|
+
"types": [
|
|
15
|
+
"jest",
|
|
16
|
+
"node"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"include": [
|
|
20
|
+
"src",
|
|
21
|
+
"examples",
|
|
22
|
+
"__tests__",
|
|
23
|
+
"jest.setup.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|