startgg-oauth2-full 0.2.1 → 0.2.2

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 CHANGED
@@ -37,24 +37,25 @@ Cooked for *you* by 0xabadbabe - using a lot of 💜 and few lines of code.
37
37
 
38
38
  ```fish
39
39
  ┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:08:50]─[G:main =]
40
- ╰─>$ npm test -- pkce
40
+ ╰─>$ npm test
41
41
 
42
- > startgg-oauth2-full@0.2.0 test
43
- > jest --runInBand pkce
42
+ > startgg-oauth2-full@0.2.2 test
43
+ > jest --runInBand
44
44
 
45
- PASS __tests__/pkce.test.ts
46
- PKCE helpers
47
- ✓ generateCodeVerifier length bounds (4 ms)
48
- ✓ computeCodeChallengeS256 deterministic (3 ms)
45
+ PASS __tests__/pkce.test.ts
46
+ PASS __tests__/handler.test.ts
47
+ PASS __tests__/authorize-url.test.ts
48
+ PASS __tests__/constants.test.ts
49
+ PASS __tests__/bearer-token.test.ts
49
50
 
50
- Test Suites: 1 passed, 1 total
51
- Tests: 2 passed, 2 total
51
+ Test Suites: 5 passed, 5 total
52
+ Tests: 28 passed, 28 total
52
53
  Snapshots: 0 total
53
- Time: 0.641 s, estimated 1 s
54
- Ran all test suites matching /pkce/i.
54
+ Time: 0.859 s, estimated 1 s
55
+ Ran all test suites.
55
56
  ┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:10:09]─[G:main =]
56
- ╰─>$
57
- [0] 0:fish* "~/d/startgg-oauth2-fu" 21:10 30-lis-25
57
+ ╰─>$
58
+ [0] 0:fish* "~/d/startgg-oauth2-fu" 21:10 26-wrz-26
58
59
  ```
59
60
 
60
61
  ## Installation
@@ -82,53 +83,245 @@ npm install @0xabadbabe-ops/startgg-oauth2-full
82
83
 
83
84
  ---
84
85
 
86
+ ## Start.gg Specifics
87
+
88
+ The library itself is spec-pure PKCE (RFC 7636) and never touches a client secret. Start.gg's deployment deviates from that spec in ways you must plan for:
89
+
90
+ - **`client_secret` is required at the token endpoint — even with PKCE.** Every Start.gg OAuth app is a confidential client. Keep the secret server-side and add it to the token request where you run the exchange. Every example in this repo shows the pattern (a small `exchangeTokenWithSecret` helper or a same-origin dev relay).
91
+ - **The token endpoint sends no CORS headers.** A browser page can never exchange the code directly against `api.start.gg`. Exchange on your server (Node, Next.js route handler, bot backend) or through a same-origin relay — see `examples/browser` for the relay approach.
92
+ - **Redirect URIs must match the registered callback exactly** — scheme, host, port, and path.
93
+ - **Authorize host.** The constants use `https://api.start.gg/oauth/authorize`. If that host ever answers the authorize request with a JSON login 404, issue the identical request (same parameters) against `https://start.gg/oauth/authorize` — it renders the login/consent screen and completes the same flow.
94
+
95
+ See [STARTGG_OAUTH_SETUP.md](./STARTGG_OAUTH_SETUP.md) for app registration, per-example redirect URIs, and environment variables.
96
+
97
+ ---
98
+
85
99
  ## Quick Start
86
100
 
87
- ### Browser (PKCE → Exchange)
101
+ ### 1. Build the authorize URL (browser or server)
88
102
 
89
103
  ```ts
90
- import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full';
104
+ import { buildAuthorizeUrl, StartGGScope, STARTGG_ENDPOINTS } from 'startgg-oauth2-full';
91
105
 
92
106
  const cfg = {
93
107
  clientId: '<client-id>',
94
- authEndpoint: 'https://api.start.gg/oauth/authorize',
108
+ authEndpoint: STARTGG_ENDPOINTS.authorize,
95
109
  redirectUri: 'https://your.app/api/startgg/callback',
96
110
  };
97
111
 
112
+ const state = crypto.randomUUID();
98
113
  const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
99
114
  scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
100
- state: crypto.randomUUID(),
115
+ state,
101
116
  });
102
117
 
118
+ // Keep both tied together for the callback (session, or a server-side state store)
103
119
  sessionStorage.setItem('pkce:verifier', codeVerifier);
104
- sessionStorage.setItem('oauth:state', '<same-state>');
120
+ sessionStorage.setItem('oauth:state', state);
105
121
  location.href = url;
106
122
  ```
107
123
 
108
- ### Callback (Exchange + Bearer)
124
+ ### 2. Exchange the code server-side
125
+
126
+ Start.gg requires `client_secret` and sends no CORS headers, so the exchange happens on your server (the snippet every example in this repo uses):
127
+
128
+ ```ts
129
+ // e.g. app/api/startgg/callback — Node / Next.js route handler
130
+ import { BearerToken } from 'startgg-oauth2-full';
131
+
132
+ export async function GET(req: Request) {
133
+ const { searchParams } = new URL(req.url);
134
+ const code = searchParams.get('code')!;
135
+ // verify `state`, then load the code_verifier you stored for it
136
+
137
+ const body = new URLSearchParams({
138
+ grant_type: 'authorization_code',
139
+ code,
140
+ redirect_uri: process.env.STARTGG_REDIRECT_URI!,
141
+ code_verifier: storedVerifier,
142
+ client_id: process.env.STARTGG_CLIENT_ID!,
143
+ client_secret: process.env.STARTGG_CLIENT_SECRET!, // required by Start.gg even with PKCE
144
+ });
145
+
146
+ const res = await fetch('https://api.start.gg/oauth/access_token', {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/x-www-form-urlencoded',
150
+ Accept: 'application/json',
151
+ },
152
+ body,
153
+ });
154
+ if (!res.ok) return new Response(`Token exchange failed: HTTP ${res.status}`, { status: 502 });
155
+
156
+ const bearer = BearerToken.fromOAuthResponse(await res.json());
157
+ // store bearer per user; never expose the raw token or your secret to the page
158
+ }
159
+ ```
160
+
161
+ Against a provider *without* Start.gg's secret requirement, `createStartGGAuth2Handler(cfg).exchangeToken(code, codeVerifier, scopes)` performs the same spec-pure POST for you — see [Advanced Usage](#advanced-usage).
162
+
163
+ ### Using Constants for Start.gg Endpoints & Scopes
164
+
165
+ ```ts
166
+ import { STARTGG_ENDPOINTS, STARTGG_SCOPES, STARTGG_GQL_AUTH_HEADER, isValidStartGGScope } from 'startgg-oauth2-full';
167
+
168
+ // All official Start.gg endpoints
169
+ console.log(STARTGG_ENDPOINTS.authorize); // https://api.start.gg/oauth/authorize
170
+ console.log(STARTGG_ENDPOINTS.token); // https://api.start.gg/oauth/access_token
171
+ console.log(STARTGG_ENDPOINTS.gql); // https://api.start.gg/gql/alpha
172
+
173
+ // Valid scopes
174
+ console.log(STARTGG_SCOPES); // ['user.identity', 'user.email', 'tournament.manager', 'tournament.reporter']
175
+ console.log(STARTGG_GQL_AUTH_HEADER); // 'Bearer'
176
+
177
+ // Scope validation helper
178
+ const userInput = 'user.identity';
179
+ if (isValidStartGGScope(userInput)) {
180
+ // TypeScript narrows to StartGGScopeValue
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Advanced Usage
187
+
188
+ ### Refresh Token Rotation
109
189
 
110
190
  ```ts
111
191
  import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-full';
112
192
 
113
- const params = new URLSearchParams(location.search);
114
- const code = params.get('code')!;
115
- const state = params.get('state')!;
116
- if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mismatch');
193
+ const handler = createStartGGAuth2Handler({ clientId, redirectUri });
117
194
 
118
- const handler = createStartGGAuth2Handler({
119
- clientId: '<client-id>',
120
- redirectUri: 'https://your.app/api/startgg/callback',
121
- authEndpoint: 'https://api.start.gg/oauth/authorize',
122
- tokenEndpoint: 'https://api.start.gg/oauth/token',
195
+ // Initial exchange
196
+ const res = await handler.exchangeToken(code, codeVerifier, [StartGGScope.USER_IDENTITY]);
197
+ let bearer = BearerToken.fromOAuthResponse(res);
198
+
199
+ // Later: refresh when expired (skew-aware)
200
+ if (bearer.willExpireWithin(60)) { // expires within 60s
201
+ const newRes = await handler.refreshToken(bearer.refreshToken!, [StartGGScope.USER_IDENTITY]);
202
+ bearer = BearerToken.fromOAuthResponse(newRes); // preserves refresh_token if server omits
203
+ }
204
+ ```
205
+
206
+ ### Custom PKCE Pair (Pre-generated Verifier/Challenge)
207
+
208
+ ```ts
209
+ import { buildAuthorizeUrl, computeCodeChallengeS256, generateCodeVerifier } from 'startgg-oauth2-full';
210
+
211
+ // Generate once, store securely
212
+ const codeVerifier = generateCodeVerifier(64);
213
+ const codeChallenge = await computeCodeChallengeS256(codeVerifier);
214
+
215
+ // Later: build URL with pre-computed pair
216
+ const { url } = await buildAuthorizeUrl(cfg, {
217
+ scopes: [StartGGScope.USER_IDENTITY],
218
+ state: crypto.randomUUID(),
219
+ codeVerifier,
220
+ codeChallenge, // validated against verifier
123
221
  });
222
+ ```
124
223
 
125
- const res = await handler.exchangeToken(code, sessionStorage.getItem('pkce:verifier')!, [
126
- StartGGScope.USER_IDENTITY,
127
- StartGGScope.USER_EMAIL,
128
- ]);
224
+ ### Server-Side (Node/Next.js) with Secure State Store
129
225
 
130
- const bearer = BearerToken.fromOAuthResponse(res);
131
- fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
226
+ ```ts
227
+ // lib/startgg.ts (Next.js example)
228
+ import { createStartGGAuth2Handler } from 'startgg-oauth2-full';
229
+
230
+ export function getStartggHandler() {
231
+ return createStartGGAuth2Handler({
232
+ clientId: process.env.STARTGG_CLIENT_ID!,
233
+ redirectUri: process.env.STARTGG_REDIRECT_URI!,
234
+ // authEndpoint and tokenEndpoint default to STARTGG_ENDPOINTS
235
+ });
236
+ }
237
+
238
+ // app/api/startgg/callback/route.ts
239
+ import { getStartggHandler } from '@/lib/startgg';
240
+ import { consumePending } from '@/lib/pendingStore'; // your secure store
241
+
242
+ export async function GET(req: Request) {
243
+ const { searchParams } = new URL(req.url);
244
+ const code = searchParams.get('code')!;
245
+ const state = searchParams.get('state')!;
246
+
247
+ const pending = consumePending(state); // delete after use
248
+ if (!pending) return new Response('Invalid state', { status: 400 });
249
+
250
+ const res = await getStartggHandler().exchangeToken(code, pending.codeVerifier, pending.scopes);
251
+ return Response.json({ ok: true, scope: res.scope });
252
+ }
253
+ ```
254
+
255
+ ### GraphQL Calls with Bearer Token
256
+
257
+ ```ts
258
+ import { BearerToken, STARTGG_ENDPOINTS, STARTGG_GQL_AUTH_HEADER } from 'startgg-oauth2-full';
259
+
260
+ const bearer = BearerToken.fromOAuthResponse(tokenResponse);
261
+
262
+ const query = `
263
+ query GetUser { user { id, name, email } }
264
+ `;
265
+
266
+ const response = await fetch(STARTGG_ENDPOINTS.gql, {
267
+ method: 'POST',
268
+ headers: {
269
+ 'Content-Type': 'application/json',
270
+ [STARTGG_GQL_AUTH_HEADER]: bearer.toAuthHeader().Authorization,
271
+ },
272
+ body: JSON.stringify({ query }),
273
+ });
274
+
275
+ const { data } = await response.json();
276
+ ```
277
+
278
+ ### Error Handling
279
+
280
+ ```ts
281
+ import { createStartGGAuth2Handler, OAuth2Error, ScopeValidationError, StartGGScope } from 'startgg-oauth2-full';
282
+
283
+ const handler = createStartGGAuth2Handler(cfg);
284
+
285
+ try {
286
+ const res = await handler.exchangeToken(code, verifier, [StartGGScope.USER_IDENTITY]);
287
+ } catch (err) {
288
+ if (err instanceof ScopeValidationError) {
289
+ console.error('Requested scopes:', err.requestedScopes); // ['user.identity', 'user.email']
290
+ console.error('Granted scopes:', err.grantedScopes); // ['user.identity'] (present when server returned scope)
291
+ } else if (err instanceof OAuth2Error) {
292
+ console.error('OAuth error:', err.code); // TOKEN_EXCHANGE_FAILED, INVALID_PKCE_PAIR, etc.
293
+ console.error('Details:', err.details); // parsed JSON or { raw: '...' }
294
+ } else {
295
+ throw err;
296
+ }
297
+ }
298
+ ```
299
+
300
+ ### Cloudflare Workers / Edge Runtime
301
+
302
+ ```ts
303
+ // Works in Cloudflare Workers, Vercel Edge, Deno, Bun
304
+ import { createStartGGAuth2Handler, StartGGScope } from 'startgg-oauth2-full';
305
+
306
+ export default {
307
+ async fetch(request: Request, env: Env): Promise<Response> {
308
+ const handler = createStartGGAuth2Handler({
309
+ clientId: env.STARTGG_CLIENT_ID,
310
+ redirectUri: new URL('/callback', request.url).href,
311
+ });
312
+
313
+ const url = new URL(request.url);
314
+ if (url.pathname === '/callback') {
315
+ const code = url.searchParams.get('code')!;
316
+ const state = url.searchParams.get('state')!;
317
+ // validate state from your KV/D1 store...
318
+ const res = await handler.exchangeToken(code, storedVerifier, [StartGGScope.USER_IDENTITY]);
319
+ return Response.redirect('/dashboard');
320
+ }
321
+
322
+ // ... rest of handler
323
+ },
324
+ };
132
325
  ```
133
326
 
134
327
  ---
@@ -136,11 +329,12 @@ fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
136
329
  ## Scripts
137
330
 
138
331
  ```bash
139
- npm run build # tsc build
140
- npm test # Jest tests (needs ts-node installed)
332
+ npm run build # compile TypeScript (tsc)
333
+ npm test # full Jest suite (ts-jest)
334
+ npm test -- pkce # target a single spec
141
335
  ```
142
336
 
143
- Examples ship as their own workspaces—hop into each folder, install once, then use the local scripts:
337
+ Examples ship as their own workspaces — hop into each folder, install once, then use the local scripts. Every example performs a real user login against Start.gg:
144
338
 
145
339
  - Browser (Vite): `cd examples/browser && npm install && npm run dev`
146
340
  - Node CLI/server: `cd examples/node && npm install && npm run dev`
@@ -161,6 +355,8 @@ Examples ship as their own workspaces—hop into each folder, install once, then
161
355
  - `BearerToken`
162
356
  - `fromOAuthResponse(res, nowMs?, skewSeconds?)`
163
357
  - `isExpired()`, `willExpireWithin()`, `toAuthHeader()`, `assertUsable()`
358
+ - `STARTGG_ENDPOINTS`, `STARTGG_SCOPES`, `STARTGG_GQL_AUTH_HEADER`
359
+ - `isValidStartGGScope(value): boolean`
164
360
 
165
361
  ### Scopes
166
362
 
@@ -177,7 +373,7 @@ enum StartGGScope {
177
373
 
178
374
  ## Scope Semantics
179
375
 
180
- - If response **includes** `scope`, it’s validated; missing required → `ScopeValidationError`.
376
+ - If response **includes** `scope`, it's validated; missing required → `ScopeValidationError`.
181
377
  - If response **omits** `scope`, treat as unchanged (RFC 6749).
182
378
  - Refresh: preserve prior `refresh_token` if omitted by server.
183
379
 
@@ -190,17 +386,73 @@ class OAuth2Error extends Error {
190
386
  code?: string; // e.g., TOKEN_EXCHANGE_FAILED
191
387
  details?: unknown; // parsed JSON or { raw: string }
192
388
  }
389
+
390
+ class ScopeValidationError extends Error {
391
+ requestedScopes: string[]; // what you asked for
392
+ grantedScopes?: string[]; // what the server granted (when it returned scope)
393
+ }
193
394
  ```
194
395
 
195
396
  ---
196
397
 
197
398
  ## Examples
198
399
 
199
- - Browser (Vite SPA): `examples/browser/`
200
- - Node CLI + redirect catcher: `examples/node/`
201
- - Discord bot (discord.js v14): `examples/discordjs/`
202
- - Next.js (App Router): `examples/nextjs/`
203
- - Frontend Vite scaffold: `examples/vite/`
400
+ | Example | Stack | Token exchange |
401
+ | --- | --- | --- |
402
+ | [`examples/node/`](./examples/node) | Node CLI + local redirect server | Server-side POST with `client_secret` |
403
+ | [`examples/nextjs/`](./examples/nextjs) | Next.js App Router | Route handler POST with `client_secret` |
404
+ | [`examples/discordjs/`](./examples/discordjs) | discord.js v14 bot | Bot backend POST with `client_secret` |
405
+ | [`examples/browser/`](./examples/browser) | Vite SPA | Same-origin dev relay (`/startgg/token`) injects the secret |
406
+ | [`examples/vite/`](./examples/vite) | Minimal Vite scaffold | Same-origin dev relay (`/startgg/token`) injects the secret |
407
+
408
+ Each example documents its redirect URI and environment variables in its own README.
409
+
410
+ ---
411
+
412
+ ## Vercel Connect Integration
413
+
414
+ Use Start.gg with [Vercel Connect](https://vercel.com/docs/connect) for secure, short-lived tokens without storing credentials in your environment.
415
+
416
+ ### Quick Setup
417
+
418
+ ```bash
419
+ # 1. Create Start.gg OAuth app (redirect: https://connect.vercel.com/callback)
420
+ # 2. Create Custom OAuth connector in Vercel Connect
421
+ vercel connect create https://api.start.gg/oauth/authorize --name startgg
422
+ # 3. Attach to your project
423
+ vercel connect attach oauth/startgg
424
+ ```
425
+
426
+ ### Use in Your Code
427
+
428
+ ```bash
429
+ npm install @vercel/connect startgg-vercel-connect
430
+ ```
431
+
432
+ ```ts
433
+ import { getConnectorUid, getLoginScopes, createTokenParams } from 'startgg-vercel-connect';
434
+ import { getTokenResponse, UserAuthorizationRequiredError } from '@vercel/connect';
435
+
436
+ const token = await getTokenResponse(
437
+ getConnectorUid(),
438
+ createTokenParams({
439
+ subject: { type: 'user', id: 'user_123' },
440
+ scopes: getLoginScopes(true),
441
+ })
442
+ );
443
+
444
+ // Use with Start.gg GraphQL API
445
+ const response = await fetch('https://api.start.gg/gql/alpha', {
446
+ method: 'POST',
447
+ headers: {
448
+ 'Content-Type': 'application/json',
449
+ 'Authorization': `Bearer ${token.token}`,
450
+ },
451
+ body: JSON.stringify({ query: '{ viewer { id name } }' }),
452
+ });
453
+ ```
454
+
455
+ See [VERCEL_CONNECT_STARTGG.md](./VERCEL_CONNECT_STARTGG.md) for the complete guide and [packages/startgg-vercel-connect](./packages/startgg-vercel-connect) for the helper package.
204
456
 
205
457
  ---
206
458
 
@@ -216,27 +468,33 @@ GitHub Actions runs TypeScript build + Jest on push/PR (Node 18 & 20). See `.git
216
468
  startgg-oauth2-full/
217
469
  ├── README.md
218
470
  ├── AGENTS.md
471
+ ├── CONTRIBUTING.md
219
472
  ├── LICENSE
473
+ ├── STARTGG_OAUTH_SETUP.md # Start.gg app registration & env vars
474
+ ├── VERCEL_CONNECT_STARTGG.md # Vercel Connect guide
220
475
  ├── package.json
221
476
  ├── tsconfig.json
222
477
  ├── jest.config.ts
223
478
  ├── jest.setup.ts
224
- ├── .gitignore
225
- ├── .npmrc
226
479
  ├── src/
227
- │ └── auth/
228
- │ └── StartGGOAuth2.ts
480
+ │ ├── auth/
481
+ │ │ └── StartGGOAuth2.ts # PKCE + handler + BearerToken
482
+ │ ├── constants.ts # STARTGG_ENDPOINTS, scopes
483
+ │ └── index.ts
229
484
  ├── __tests__/
230
485
  │ ├── authorize-url.test.ts
231
486
  │ ├── bearer-token.test.ts
487
+ │ ├── constants.test.ts
232
488
  │ ├── handler.test.ts
233
489
  │ └── pkce.test.ts
234
490
  ├── examples/
235
- │ ├── browser/ # Vanilla browser Vite demo
236
- │ ├── node/ # CLI + local redirect server
237
- │ ├── discordjs/ # Discord bot OAuth flow
238
- │ ├── nextjs/ # Next.js App Router example
239
- │ └── vite/ # Minimal Vite SPA scaffold
491
+ │ ├── browser/ # Vite SPA — dev relay exchange
492
+ │ ├── node/ # CLI + local redirect server
493
+ │ ├── discordjs/ # Discord bot OAuth flow
494
+ │ ├── nextjs/ # Next.js App Router example
495
+ │ └── vite/ # Minimal Vite SPA scaffold
496
+ ├── packages/
497
+ │ └── startgg-vercel-connect/
240
498
  └── .github/
241
499
  ├── ISSUE_TEMPLATE/
242
500
  │ ├── bug_report.md
@@ -252,6 +510,13 @@ startgg-oauth2-full/
252
510
  - Use and verify `state`.
253
511
  - Keep `code_verifier` private.
254
512
  - Never log tokens; always HTTPS.
513
+ - Never ship `client_secret` to the browser — Start.gg requires it, so the exchange belongs on a server you control.
514
+
515
+ ---
516
+
517
+ ## Contributing
518
+
519
+ See [CONTRIBUTING.md](./CONTRIBUTING.md). PRs should include Jest coverage for new behaviour and list validation steps (`npm test`, demo transcripts for interactive flows).
255
520
 
256
521
  ---
257
522
 
@@ -50,7 +50,7 @@ export declare function buildAuthorizeUrl(cfg: {
50
50
  }, opts: AuthorizeUrlOptions): Promise<BuiltAuthorizeUrl>;
51
51
  export declare class BearerToken {
52
52
  readonly accessToken: string;
53
- readonly tokenType: 'Bearer';
53
+ readonly tokenType: "Bearer";
54
54
  readonly refreshToken?: string;
55
55
  readonly expiresAt?: number;
56
56
  private constructor();
@@ -71,14 +71,15 @@ export declare class StartGGOAuth2Handler implements IOAuth2HandlerWithPKCE {
71
71
  });
72
72
  /** Exchange authorization code for tokens (PKCE). */
73
73
  exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
74
- /** Refresh access token; preserve prior refresh token if server omits rotation. */
74
+ /** Refresh access token; preserve prior refresh token if server omits rotation.
75
+ * Per RFC 6749, scope should only be included when requesting a subset of original scopes. */
75
76
  refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
76
77
  }
77
78
  /** Factory */
78
79
  export declare function createStartGGAuth2Handler(params: {
79
80
  clientId: string;
80
81
  redirectUri: string;
81
- authEndpoint: string;
82
- tokenEndpoint: string;
82
+ authEndpoint?: string;
83
+ tokenEndpoint?: string;
83
84
  fetchTimeoutMs?: number;
84
85
  }): StartGGOAuth2Handler;
@@ -1,4 +1,5 @@
1
- // Full RFC-compliant implementation
1
+ import { STARTGG_ENDPOINTS } from "../constants";
2
+ // Full RFC-compliant implementation
2
3
  // of OAuth2 Authorization Code Flow with PKCE (RFC 6749, RFC 7636)
3
4
  // for Start.gg API (https://start.gg/docs/oauth2).
4
5
  // Happy to accept PRs for improvements or fixes!
@@ -16,7 +17,7 @@ export class OAuth2Error extends Error {
16
17
  details;
17
18
  constructor(message, code, details) {
18
19
  super(message);
19
- this.name = 'OAuth2Error';
20
+ this.name = "OAuth2Error";
20
21
  this.code = code;
21
22
  this.details = details;
22
23
  }
@@ -25,7 +26,7 @@ export class ScopeValidationError extends OAuth2Error {
25
26
  requestedScopes;
26
27
  grantedScopes;
27
28
  constructor(message, requestedScopes, grantedScopes) {
28
- super(message, 'SCOPE_VALIDATION_FAILED');
29
+ super(message, "SCOPE_VALIDATION_FAILED");
29
30
  this.requestedScopes = requestedScopes;
30
31
  this.grantedScopes = grantedScopes;
31
32
  }
@@ -35,31 +36,34 @@ async function getSubtleCrypto() {
35
36
  const g = globalThis;
36
37
  if (g.crypto?.subtle)
37
38
  return g.crypto.subtle;
38
- throw new OAuth2Error('WebCrypto subtle not available; required for PKCE S256', 'CRYPTO_UNAVAILABLE');
39
+ throw new OAuth2Error("WebCrypto subtle not available; required for PKCE S256", "CRYPTO_UNAVAILABLE");
39
40
  }
40
41
  /** Base64 (Buffer if available, else btoa path). */
41
42
  function base64Encode(bytes) {
42
43
  const g = globalThis;
43
- if (typeof g.Buffer?.from === 'function')
44
- return g.Buffer.from(bytes).toString('base64');
45
- let binary = '';
44
+ if (typeof g.Buffer?.from === "function")
45
+ return g.Buffer.from(bytes).toString("base64");
46
+ let binary = "";
46
47
  const chunk = 0x8000;
47
48
  for (let i = 0; i < bytes.length; i += chunk)
48
49
  binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
49
- if (typeof g.btoa !== 'function')
50
- throw new OAuth2Error('btoa not available for base64 encoding', 'B64_UNAVAILABLE');
50
+ if (typeof g.btoa !== "function")
51
+ throw new OAuth2Error("btoa not available for base64 encoding", "B64_UNAVAILABLE");
51
52
  return g.btoa(binary);
52
53
  }
53
54
  /** URL-safe Base64 (no padding). */
54
55
  function base64Url(bytes) {
55
56
  const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
56
- return base64Encode(u8).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
57
+ return base64Encode(u8)
58
+ .replace(/\+/g, "-")
59
+ .replace(/\//g, "_")
60
+ .replace(/=+$/g, "");
57
61
  }
58
62
  /** Random bytes using WebCrypto. */
59
63
  function getRandomBytes(length) {
60
64
  const g = globalThis;
61
65
  if (!g.crypto?.getRandomValues)
62
- throw new OAuth2Error('crypto.getRandomValues unavailable', 'RNG_UNAVAILABLE');
66
+ throw new OAuth2Error("crypto.getRandomValues unavailable", "RNG_UNAVAILABLE");
63
67
  const bytes = new Uint8Array(length);
64
68
  g.crypto.getRandomValues(bytes);
65
69
  return bytes;
@@ -83,12 +87,12 @@ async function parseJsonSafe(res) {
83
87
  }
84
88
  /** Validate token response + Bearer type. */
85
89
  function validateTokenResponse(tr) {
86
- if (!tr || typeof tr !== 'object')
87
- throw new OAuth2Error('Invalid token response shape', 'INVALID_TOKEN_RESPONSE', tr);
90
+ if (!tr || typeof tr !== "object")
91
+ throw new OAuth2Error("Invalid token response shape", "INVALID_TOKEN_RESPONSE", tr);
88
92
  if (!tr.access_token)
89
- throw new OAuth2Error('Missing access_token', 'INVALID_TOKEN_RESPONSE', tr);
90
- if (!tr.token_type || tr.token_type.toLowerCase() !== 'bearer') {
91
- throw new OAuth2Error('Unsupported token_type', 'UNSUPPORTED_TOKEN_TYPE', tr);
93
+ throw new OAuth2Error("Missing access_token", "INVALID_TOKEN_RESPONSE", tr);
94
+ if (!tr.token_type || tr.token_type.toLowerCase() !== "bearer") {
95
+ throw new OAuth2Error("Unsupported token_type", "UNSUPPORTED_TOKEN_TYPE", tr);
92
96
  }
93
97
  }
94
98
  /** If scope omitted, assume unchanged (RFC 6749). */
@@ -98,9 +102,9 @@ function validateScopesOrAssumePrevious(responseScope, requiredScopes) {
98
102
  if (responseScope == null)
99
103
  return;
100
104
  const granted = new Set(responseScope.split(/\s+/).filter(Boolean));
101
- const missing = requiredScopes.filter(s => !granted.has(s));
105
+ const missing = requiredScopes.filter((s) => !granted.has(s));
102
106
  if (missing.length > 0) {
103
- throw new ScopeValidationError(`Missing required scopes: ${missing.join(', ')}`, requiredScopes, Array.from(granted));
107
+ throw new ScopeValidationError(`Missing required scopes: ${missing.join(", ")}`, requiredScopes, Array.from(granted));
104
108
  }
105
109
  }
106
110
  /** Fetch with timeout to avoid hangs. */
@@ -116,7 +120,7 @@ async function fetchWithTimeout(input, init = {}) {
116
120
  }
117
121
  }
118
122
  // -------- PKCE (RFC 7636) --------
119
- const PKCE_VERIFIER_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
123
+ const PKCE_VERIFIER_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
120
124
  export function generateCodeVerifier(length = 64) {
121
125
  const len = Math.min(Math.max(length, 43), 128);
122
126
  const alphabet = PKCE_VERIFIER_CHARS;
@@ -133,13 +137,13 @@ export function generateCodeVerifier(length = 64) {
133
137
  break;
134
138
  }
135
139
  }
136
- return result.join('');
140
+ return result.join("");
137
141
  }
138
142
  /** S256 challenge for a verifier. */
139
143
  export async function computeCodeChallengeS256(codeVerifier) {
140
144
  const enc = new TextEncoder();
141
145
  const subtle = await getSubtleCrypto();
142
- const hash = await subtle.digest('SHA-256', enc.encode(codeVerifier));
146
+ const hash = await subtle.digest("SHA-256", enc.encode(codeVerifier));
143
147
  return base64Url(hash);
144
148
  }
145
149
  /** Build an authorization URL with PKCE (S256). */
@@ -149,7 +153,7 @@ export async function buildAuthorizeUrl(cfg, opts) {
149
153
  if (opts.codeVerifier && opts.codeChallenge) {
150
154
  const expectedChallenge = await computeCodeChallengeS256(opts.codeVerifier);
151
155
  if (expectedChallenge !== opts.codeChallenge) {
152
- throw new OAuth2Error('Provided codeChallenge does not match codeVerifier', 'INVALID_PKCE_PAIR');
156
+ throw new OAuth2Error("Provided codeChallenge does not match codeVerifier", "INVALID_PKCE_PAIR");
153
157
  }
154
158
  codeVerifier = opts.codeVerifier;
155
159
  codeChallenge = opts.codeChallenge;
@@ -159,7 +163,7 @@ export async function buildAuthorizeUrl(cfg, opts) {
159
163
  codeChallenge = await computeCodeChallengeS256(codeVerifier);
160
164
  }
161
165
  else if (opts.codeChallenge) {
162
- throw new OAuth2Error('codeVerifier is required when providing codeChallenge', 'INVALID_PKCE_PAIR');
166
+ throw new OAuth2Error("codeVerifier is required when providing codeChallenge", "INVALID_PKCE_PAIR");
163
167
  }
164
168
  else {
165
169
  codeVerifier = generateCodeVerifier();
@@ -167,12 +171,12 @@ export async function buildAuthorizeUrl(cfg, opts) {
167
171
  }
168
172
  const u = new URL(cfg.authEndpoint);
169
173
  const params = {
170
- response_type: 'code',
174
+ response_type: "code",
171
175
  client_id: cfg.clientId,
172
176
  redirect_uri: cfg.redirectUri,
173
- scope: opts.scopes.map(String).join(' '),
177
+ scope: opts.scopes.map(String).join(" "),
174
178
  code_challenge: codeChallenge,
175
- code_challenge_method: 'S256',
179
+ code_challenge_method: "S256",
176
180
  };
177
181
  if (opts.state)
178
182
  params.state = opts.state;
@@ -201,12 +205,13 @@ export class BearerToken {
201
205
  }
202
206
  static fromOAuthResponse(res, nowMs = Date.now(), skewSeconds = 60) {
203
207
  validateTokenResponse(res);
204
- const expiresAt = typeof res.expires_in === 'number'
205
- ? nowMs + Math.max(0, (res.expires_in - Math.max(0, skewSeconds)) * 1000)
208
+ const expiresAt = typeof res.expires_in === "number"
209
+ ? nowMs +
210
+ Math.max(0, (res.expires_in - Math.max(0, skewSeconds)) * 1000)
206
211
  : undefined;
207
212
  return new BearerToken({
208
213
  accessToken: res.access_token,
209
- tokenType: 'Bearer',
214
+ tokenType: "Bearer",
210
215
  refreshToken: res.refresh_token,
211
216
  expiresAt,
212
217
  });
@@ -226,7 +231,7 @@ export class BearerToken {
226
231
  }
227
232
  assertUsable(nowMs = Date.now()) {
228
233
  if (this.isExpired(nowMs))
229
- throw new OAuth2Error('Access token expired', 'TOKEN_EXPIRED');
234
+ throw new OAuth2Error("Access token expired", "TOKEN_EXPIRED");
230
235
  }
231
236
  }
232
237
  // -------- Handler (RFC 6749 §4.1.3, §6) --------
@@ -238,44 +243,50 @@ export class StartGGOAuth2Handler {
238
243
  /** Exchange authorization code for tokens (PKCE). */
239
244
  async exchangeToken(code, codeVerifier, expectedScopes) {
240
245
  const tokenRequest = {
241
- grant_type: 'authorization_code',
246
+ grant_type: "authorization_code",
242
247
  code,
243
248
  redirect_uri: this.config.redirectUri,
244
249
  code_verifier: codeVerifier,
245
250
  client_id: this.config.clientId,
246
251
  };
247
252
  const res = await fetchWithTimeout(this.config.tokenEndpoint, {
248
- method: 'POST',
249
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/x-www-form-urlencoded",
256
+ Accept: "application/json",
257
+ },
250
258
  body: formBody(tokenRequest),
251
259
  timeoutMs: this.config.fetchTimeoutMs ?? 15000,
252
260
  });
253
261
  if (!res.ok) {
254
262
  const details = await parseJsonSafe(res);
255
- throw new OAuth2Error('Token exchange failed', 'TOKEN_EXCHANGE_FAILED', details);
263
+ throw new OAuth2Error("Token exchange failed", "TOKEN_EXCHANGE_FAILED", details);
256
264
  }
257
265
  const tokenResponse = (await res.json());
258
266
  validateTokenResponse(tokenResponse);
259
267
  validateScopesOrAssumePrevious(tokenResponse.scope, expectedScopes.map(String));
260
268
  return tokenResponse;
261
269
  }
262
- /** Refresh access token; preserve prior refresh token if server omits rotation. */
270
+ /** Refresh access token; preserve prior refresh token if server omits rotation.
271
+ * Per RFC 6749, scope should only be included when requesting a subset of original scopes. */
263
272
  async refreshToken(refreshToken, originalScopes) {
264
273
  const refreshRequest = {
265
- grant_type: 'refresh_token',
274
+ grant_type: "refresh_token",
266
275
  refresh_token: refreshToken,
267
276
  client_id: this.config.clientId,
268
- scope: originalScopes.length ? originalScopes.join(' ') : undefined,
269
277
  };
270
278
  const res = await fetchWithTimeout(this.config.tokenEndpoint, {
271
- method: 'POST',
272
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
279
+ method: "POST",
280
+ headers: {
281
+ "Content-Type": "application/x-www-form-urlencoded",
282
+ Accept: "application/json",
283
+ },
273
284
  body: formBody(Object.fromEntries(Object.entries(refreshRequest).filter(([, v]) => v != null))),
274
285
  timeoutMs: this.config.fetchTimeoutMs ?? 15000,
275
286
  });
276
287
  if (!res.ok) {
277
288
  const details = await parseJsonSafe(res);
278
- throw new OAuth2Error('Token refresh failed', 'TOKEN_REFRESH_FAILED', details);
289
+ throw new OAuth2Error("Token refresh failed", "TOKEN_REFRESH_FAILED", details);
279
290
  }
280
291
  const tokenResponse = (await res.json());
281
292
  validateTokenResponse(tokenResponse);
@@ -287,5 +298,9 @@ export class StartGGOAuth2Handler {
287
298
  }
288
299
  /** Factory */
289
300
  export function createStartGGAuth2Handler(params) {
290
- return new StartGGOAuth2Handler(params);
301
+ return new StartGGOAuth2Handler({
302
+ ...params,
303
+ authEndpoint: params.authEndpoint ?? STARTGG_ENDPOINTS.authorize,
304
+ tokenEndpoint: params.tokenEndpoint ?? STARTGG_ENDPOINTS.token,
305
+ });
291
306
  }
@@ -0,0 +1,10 @@
1
+ export declare const STARTGG_ENDPOINTS: {
2
+ readonly authorize: "https://api.start.gg/oauth/authorize";
3
+ readonly token: "https://api.start.gg/oauth/access_token";
4
+ readonly refresh: "https://api.start.gg/oauth/refresh";
5
+ readonly gql: "https://api.start.gg/gql/alpha";
6
+ };
7
+ export declare const STARTGG_SCOPES: readonly ["user.identity", "user.email", "tournament.manager", "tournament.reporter"];
8
+ export declare const STARTGG_GQL_AUTH_HEADER: "Bearer";
9
+ export type StartGGScopeValue = (typeof STARTGG_SCOPES)[number];
10
+ export declare function isValidStartGGScope(scope: string): scope is StartGGScopeValue;
@@ -0,0 +1,16 @@
1
+ export const STARTGG_ENDPOINTS = {
2
+ authorize: "https://api.start.gg/oauth/authorize",
3
+ token: "https://api.start.gg/oauth/access_token",
4
+ refresh: "https://api.start.gg/oauth/refresh",
5
+ gql: "https://api.start.gg/gql/alpha",
6
+ };
7
+ export const STARTGG_SCOPES = [
8
+ "user.identity",
9
+ "user.email",
10
+ "tournament.manager",
11
+ "tournament.reporter",
12
+ ];
13
+ export const STARTGG_GQL_AUTH_HEADER = "Bearer";
14
+ export function isValidStartGGScope(scope) {
15
+ return STARTGG_SCOPES.includes(scope);
16
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,2 @@
1
- export * from './auth/StartGGOAuth2.js';
1
+ export * from "./auth/StartGGOAuth2.js";
2
+ export * from "./constants.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
- export * from './auth/StartGGOAuth2.js';
1
+ export * from "./auth/StartGGOAuth2.js";
2
+ export * from "./constants.js";
package/package.json CHANGED
@@ -1,37 +1,65 @@
1
1
  {
2
- "name": "startgg-oauth2-full",
3
- "version": "0.2.1",
4
- "type": "module",
5
- "main": "./dist/index.js",
6
- "types": "./dist/index.d.ts",
7
- "exports": {
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "import": "./dist/index.js"
11
- }
12
- },
13
- "files": [
14
- "dist",
15
- "README.md",
16
- "LICENSE"
17
- ],
18
- "license": "MIT",
19
- "scripts": {
20
- "dev:browser": "serve examples/browser -p 5174",
21
- "dev:node": "tsx examples/node/index.ts",
22
- "dev:node:server": "tsx examples/node/server.ts",
23
- "test": "jest --runInBand",
24
- "build": "tsc -p tsconfig.json"
25
- },
26
- "devDependencies": {
27
- "@types/jest": "^29.5.12",
28
- "@types/node": "^22.7.5",
29
- "jest": "^29.7.0",
30
- "node-fetch": "^3.3.2",
31
- "serve": "^14.2.6",
32
- "ts-jest": "^29.2.5",
33
- "ts-node": "10.9.2",
34
- "tsx": "^4.19.0",
35
- "typescript": "^5.6.3"
36
- }
2
+ "name": "startgg-oauth2-full",
3
+ "version": "0.2.2",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./auth": {
13
+ "types": "./dist/auth/StartGGOAuth2.d.ts",
14
+ "import": "./dist/auth/StartGGOAuth2.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/0xABADBABE-ops/startgg-oauth2-full.git"
26
+ },
27
+ "scripts": {
28
+ "dev:browser": "serve examples/browser -p 5174",
29
+ "dev:node": "tsx examples/node/src/index.ts",
30
+ "dev:node:server": "tsx examples/node/src/server.ts",
31
+ "dev:vite": "cd examples/vite && npm run dev",
32
+ "dev:nextjs": "cd examples/nextjs && npm run dev",
33
+ "dev:discord": "cd examples/discordjs && npm run dev",
34
+ "build:vercel-connect": "cd packages/startgg-vercel-connect && npm run build",
35
+ "test": "jest --runInBand",
36
+ "test:watch": "jest --watch",
37
+ "test:coverage": "jest --coverage",
38
+ "lint": "tsc --noEmit",
39
+ "build": "tsc -p tsconfig.json",
40
+ "prepare": "npm run build"
41
+ },
42
+ "devDependencies": {
43
+ "@types/jest": "^29.5.12",
44
+ "@types/node": "^22.7.5",
45
+ "jest": "^29.7.0",
46
+ "lantern": "0.1.2",
47
+ "node-fetch": "^3.3.2",
48
+ "serve": "^14.2.6",
49
+ "ts-jest": "^29.2.5",
50
+ "ts-node": "10.9.2",
51
+ "tsx": "^4.19.0",
52
+ "typescript": "^5.6.3"
53
+ },
54
+ "engines": {
55
+ "node": ">=18.0.0"
56
+ },
57
+ "sideEffects": false,
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org/"
61
+ },
62
+ "allowScripts": {
63
+ "esbuild@0.25.11": true
64
+ }
37
65
  }