startgg-oauth2-full 0.1.0 β†’ 0.2.1

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 (56) hide show
  1. package/README.md +28 -10
  2. package/dist/auth/StartGGOAuth2.d.ts +84 -0
  3. package/dist/auth/StartGGOAuth2.js +291 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/package.json +15 -2
  7. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -18
  8. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -13
  9. package/.github/pull_request_template.md +0 -30
  10. package/.github/workflows/ci.yml +0 -23
  11. package/CONTRIBUTING.md +0 -36
  12. package/STARTGG_OAUTH_SETUP.md +0 -41
  13. package/__tests__/authorize-url.test.ts +0 -62
  14. package/__tests__/bearer-token.test.ts +0 -21
  15. package/__tests__/handler.test.ts +0 -111
  16. package/__tests__/pkce.test.ts +0 -17
  17. package/examples/browser/README.md +0 -20
  18. package/examples/browser/index.html +0 -55
  19. package/examples/browser/package.json +0 -17
  20. package/examples/browser/src/main.ts +0 -105
  21. package/examples/browser/tsconfig.json +0 -11
  22. package/examples/browser/vite.config.ts +0 -8
  23. package/examples/discordjs/.env.example +0 -9
  24. package/examples/discordjs/README.md +0 -36
  25. package/examples/discordjs/package.json +0 -23
  26. package/examples/discordjs/src/bot.ts +0 -202
  27. package/examples/discordjs/tsconfig.json +0 -12
  28. package/examples/nextjs/.env.example +0 -7
  29. package/examples/nextjs/README.md +0 -33
  30. package/examples/nextjs/app/api/startgg/auth-url/route.ts +0 -36
  31. package/examples/nextjs/app/api/startgg/callback/route.ts +0 -55
  32. package/examples/nextjs/app/globals.css +0 -48
  33. package/examples/nextjs/app/layout.tsx +0 -15
  34. package/examples/nextjs/app/page.tsx +0 -93
  35. package/examples/nextjs/lib/pendingStore.ts +0 -37
  36. package/examples/nextjs/lib/startgg.ts +0 -28
  37. package/examples/nextjs/next-env.d.ts +0 -5
  38. package/examples/nextjs/next.config.mjs +0 -6
  39. package/examples/nextjs/package.json +0 -25
  40. package/examples/nextjs/tsconfig.json +0 -21
  41. package/examples/node/.env.example +0 -4
  42. package/examples/node/README.md +0 -27
  43. package/examples/node/package.json +0 -17
  44. package/examples/node/src/index.ts +0 -57
  45. package/examples/node/src/server.ts +0 -120
  46. package/examples/node/tsconfig.json +0 -12
  47. package/examples/vite/README.md +0 -22
  48. package/examples/vite/index.html +0 -41
  49. package/examples/vite/package.json +0 -18
  50. package/examples/vite/src/main.ts +0 -38
  51. package/examples/vite/tsconfig.json +0 -11
  52. package/examples/vite/vite.config.ts +0 -8
  53. package/jest.config.ts +0 -15
  54. package/jest.setup.ts +0 -29
  55. package/src/auth/StartGGOAuth2.ts +0 -378
  56. package/tsconfig.json +0 -25
package/README.md CHANGED
@@ -4,8 +4,11 @@
4
4
  <a href="https://github.com/0xabadbabe-ops/startgg-oauth2-full/actions/workflows/ci.yml">
5
5
  <img alt="CI" src="https://img.shields.io/github/actions/workflow/status/0xabadbabe-ops/startgg-oauth2-full/ci.yml?branch=main">
6
6
  </a>
7
- <a href="https://www.npmjs.com/package/startgg-oauth2-pkce">
8
- <img alt="npm" src="https://img.shields.io/npm/v/startgg-oauth2-pkce">
7
+ <a href="https://www.npmjs.com/package/startgg-oauth2-full">
8
+ <img alt="npm" src="https://img.shields.io/npm/v/startgg-oauth2-full">
9
+ </a>
10
+ <a href="https://github.com/0xabadbabe-ops/startgg-oauth2-full/pkgs/npm/startgg-oauth2-full">
11
+ <img alt="GitHub Packages" src="https://img.shields.io/github/package-json/v/0xabadbabe-ops/startgg-oauth2-full?label=github%20pkg">
9
12
  </a>
10
13
  <a href="./LICENSE">
11
14
  <img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg">
@@ -33,7 +36,10 @@ Cooked for *you* by 0xabadbabe - using a lot of πŸ’œ and few lines of code.
33
36
  ... with hope tha this would help for any dev struggling with oauth2 start.gg specific.
34
37
 
35
38
  ```fish
36
- startgg-oauth2-full@0.1.0 test
39
+ ┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:08:50]─[G:main =]
40
+ ╰─>$ npm test -- pkce
41
+
42
+ > startgg-oauth2-full@0.2.0 test
37
43
  > jest --runInBand pkce
38
44
 
39
45
  PASS __tests__/pkce.test.ts
@@ -44,17 +50,29 @@ startgg-oauth2-full@0.1.0 test
44
50
  Test Suites: 1 passed, 1 total
45
51
  Tests: 2 passed, 2 total
46
52
  Snapshots: 0 total
47
- Time: 1.121 s, estimated 2 s
53
+ Time: 0.641 s, estimated 1 s
48
54
  Ran all test suites matching /pkce/i.
55
+ ┬─[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
49
58
  ```
50
59
 
51
60
  ## Installation
52
61
 
53
62
  ```bash
54
- npm i startgg-oauth2-pkce
63
+ npm i startgg-oauth2-full
55
64
  # or copy src/auth/StartGGOAuth2.ts into your project
56
65
  ```
57
66
 
67
+ ### GitHub Packages (optional)
68
+
69
+ This library is also mirrored to GitHub Packages if your environment prefers that registry:
70
+
71
+ ```bash
72
+ npm set //npm.pkg.github.com/:_authToken=<GH_TOKEN_WITH_PACKAGES_SCOPE>
73
+ npm install @0xabadbabe-ops/startgg-oauth2-full
74
+ ```
75
+
58
76
  ---
59
77
 
60
78
  ## Requirements
@@ -69,12 +87,12 @@ npm i startgg-oauth2-pkce
69
87
  ### Browser (PKCE β†’ Exchange)
70
88
 
71
89
  ```ts
72
- import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-pkce';
90
+ import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full';
73
91
 
74
92
  const cfg = {
75
93
  clientId: '<client-id>',
76
94
  authEndpoint: 'https://api.start.gg/oauth/authorize',
77
- redirectUri: 'https://your.app/callback',
95
+ redirectUri: 'https://your.app/api/startgg/callback',
78
96
  };
79
97
 
80
98
  const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
@@ -90,7 +108,7 @@ location.href = url;
90
108
  ### Callback (Exchange + Bearer)
91
109
 
92
110
  ```ts
93
- import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-pkce';
111
+ import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-full';
94
112
 
95
113
  const params = new URLSearchParams(location.search);
96
114
  const code = params.get('code')!;
@@ -99,7 +117,7 @@ if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mism
99
117
 
100
118
  const handler = createStartGGAuth2Handler({
101
119
  clientId: '<client-id>',
102
- redirectUri: 'https://your.app/callback',
120
+ redirectUri: 'https://your.app/api/startgg/callback',
103
121
  authEndpoint: 'https://api.start.gg/oauth/authorize',
104
122
  tokenEndpoint: 'https://api.start.gg/oauth/token',
105
123
  });
@@ -240,7 +258,7 @@ startgg-oauth2-full/
240
258
  ## License
241
259
 
242
260
  **MIT License**
243
- Copyright Β© 2025 0xABADBABE-ops
261
+ Copyright Β© 2026 0xABADBABE-ops
244
262
 
245
263
  Permission is hereby granted, free of charge, to any person obtaining a copy
246
264
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,84 @@
1
+ export declare enum StartGGScope {
2
+ USER_IDENTITY = "user.identity",
3
+ USER_EMAIL = "user.email",
4
+ TOURNAMENT_MANAGER = "tournament.manager",
5
+ TOURNAMENT_REPORTER = "tournament.reporter"
6
+ }
7
+ export interface IOAuth2HandlerWithPKCE {
8
+ exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
9
+ refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
10
+ }
11
+ export declare class OAuth2Error extends Error {
12
+ readonly code?: string;
13
+ readonly details?: unknown;
14
+ constructor(message: string, code?: string, details?: unknown);
15
+ }
16
+ export interface OAuth2TokenResponse {
17
+ access_token: string;
18
+ token_type: string;
19
+ expires_in?: number;
20
+ refresh_token?: string;
21
+ scope?: string;
22
+ [k: string]: unknown;
23
+ }
24
+ export declare class ScopeValidationError extends OAuth2Error {
25
+ readonly requestedScopes: string[];
26
+ readonly grantedScopes?: string[] | undefined;
27
+ constructor(message: string, requestedScopes: string[], grantedScopes?: string[] | undefined);
28
+ }
29
+ export declare function generateCodeVerifier(length?: number): string;
30
+ /** S256 challenge for a verifier. */
31
+ export declare function computeCodeChallengeS256(codeVerifier: string): Promise<string>;
32
+ export type AuthorizeUrlOptions = {
33
+ scopes: (StartGGScope | string)[];
34
+ state?: string;
35
+ prompt?: string;
36
+ codeVerifier?: string;
37
+ codeChallenge?: string;
38
+ extras?: Record<string, string | number | boolean | undefined>;
39
+ };
40
+ export type BuiltAuthorizeUrl = {
41
+ url: string;
42
+ codeVerifier: string;
43
+ codeChallenge: string;
44
+ };
45
+ /** Build an authorization URL with PKCE (S256). */
46
+ export declare function buildAuthorizeUrl(cfg: {
47
+ clientId: string;
48
+ authEndpoint: string;
49
+ redirectUri: string;
50
+ }, opts: AuthorizeUrlOptions): Promise<BuiltAuthorizeUrl>;
51
+ export declare class BearerToken {
52
+ readonly accessToken: string;
53
+ readonly tokenType: 'Bearer';
54
+ readonly refreshToken?: string;
55
+ readonly expiresAt?: number;
56
+ private constructor();
57
+ static fromOAuthResponse(res: OAuth2TokenResponse, nowMs?: number, skewSeconds?: number): BearerToken;
58
+ isExpired(nowMs?: number): boolean;
59
+ willExpireWithin(seconds: number, nowMs?: number): boolean;
60
+ toAuthHeader(): Record<string, string>;
61
+ assertUsable(nowMs?: number): void;
62
+ }
63
+ export declare class StartGGOAuth2Handler implements IOAuth2HandlerWithPKCE {
64
+ private readonly config;
65
+ constructor(config: {
66
+ clientId: string;
67
+ redirectUri: string;
68
+ authEndpoint: string;
69
+ tokenEndpoint: string;
70
+ fetchTimeoutMs?: number;
71
+ });
72
+ /** Exchange authorization code for tokens (PKCE). */
73
+ exchangeToken(code: string, codeVerifier: string, expectedScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
74
+ /** Refresh access token; preserve prior refresh token if server omits rotation. */
75
+ refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
76
+ }
77
+ /** Factory */
78
+ export declare function createStartGGAuth2Handler(params: {
79
+ clientId: string;
80
+ redirectUri: string;
81
+ authEndpoint: string;
82
+ tokenEndpoint: string;
83
+ fetchTimeoutMs?: number;
84
+ }): StartGGOAuth2Handler;
@@ -0,0 +1,291 @@
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) 2026 0xabadbabe (jet'aime), Inc. (MIT License)
6
+ // Happy coding! <3
7
+ export var StartGGScope;
8
+ (function (StartGGScope) {
9
+ StartGGScope["USER_IDENTITY"] = "user.identity";
10
+ StartGGScope["USER_EMAIL"] = "user.email";
11
+ StartGGScope["TOURNAMENT_MANAGER"] = "tournament.manager";
12
+ StartGGScope["TOURNAMENT_REPORTER"] = "tournament.reporter";
13
+ })(StartGGScope || (StartGGScope = {}));
14
+ export class OAuth2Error extends Error {
15
+ code;
16
+ details;
17
+ constructor(message, code, details) {
18
+ super(message);
19
+ this.name = 'OAuth2Error';
20
+ this.code = code;
21
+ this.details = details;
22
+ }
23
+ }
24
+ export class ScopeValidationError extends OAuth2Error {
25
+ requestedScopes;
26
+ grantedScopes;
27
+ constructor(message, requestedScopes, grantedScopes) {
28
+ super(message, 'SCOPE_VALIDATION_FAILED');
29
+ this.requestedScopes = requestedScopes;
30
+ this.grantedScopes = grantedScopes;
31
+ }
32
+ }
33
+ /** WebCrypto subtle (browser + Node >=18). */
34
+ async function getSubtleCrypto() {
35
+ const g = globalThis;
36
+ if (g.crypto?.subtle)
37
+ return g.crypto.subtle;
38
+ throw new OAuth2Error('WebCrypto subtle not available; required for PKCE S256', 'CRYPTO_UNAVAILABLE');
39
+ }
40
+ /** Base64 (Buffer if available, else btoa path). */
41
+ function base64Encode(bytes) {
42
+ const g = globalThis;
43
+ if (typeof g.Buffer?.from === 'function')
44
+ return g.Buffer.from(bytes).toString('base64');
45
+ let binary = '';
46
+ const chunk = 0x8000;
47
+ for (let i = 0; i < bytes.length; i += chunk)
48
+ 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');
51
+ return g.btoa(binary);
52
+ }
53
+ /** URL-safe Base64 (no padding). */
54
+ function base64Url(bytes) {
55
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
56
+ return base64Encode(u8).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
57
+ }
58
+ /** Random bytes using WebCrypto. */
59
+ function getRandomBytes(length) {
60
+ const g = globalThis;
61
+ if (!g.crypto?.getRandomValues)
62
+ throw new OAuth2Error('crypto.getRandomValues unavailable', 'RNG_UNAVAILABLE');
63
+ const bytes = new Uint8Array(length);
64
+ g.crypto.getRandomValues(bytes);
65
+ return bytes;
66
+ }
67
+ /** x-www-form-urlencoded body. */
68
+ function formBody(params) {
69
+ const sp = new URLSearchParams();
70
+ for (const [k, v] of Object.entries(params))
71
+ sp.set(k, v);
72
+ return sp.toString();
73
+ }
74
+ /** Parse non-JSON error bodies safely. */
75
+ async function parseJsonSafe(res) {
76
+ const txt = await res.text();
77
+ try {
78
+ return JSON.parse(txt);
79
+ }
80
+ catch {
81
+ return { raw: txt };
82
+ }
83
+ }
84
+ /** Validate token response + Bearer type. */
85
+ function validateTokenResponse(tr) {
86
+ if (!tr || typeof tr !== 'object')
87
+ throw new OAuth2Error('Invalid token response shape', 'INVALID_TOKEN_RESPONSE', tr);
88
+ 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);
92
+ }
93
+ }
94
+ /** If scope omitted, assume unchanged (RFC 6749). */
95
+ function validateScopesOrAssumePrevious(responseScope, requiredScopes) {
96
+ if (!requiredScopes.length)
97
+ return;
98
+ if (responseScope == null)
99
+ return;
100
+ const granted = new Set(responseScope.split(/\s+/).filter(Boolean));
101
+ const missing = requiredScopes.filter(s => !granted.has(s));
102
+ if (missing.length > 0) {
103
+ throw new ScopeValidationError(`Missing required scopes: ${missing.join(', ')}`, requiredScopes, Array.from(granted));
104
+ }
105
+ }
106
+ /** Fetch with timeout to avoid hangs. */
107
+ async function fetchWithTimeout(input, init = {}) {
108
+ const { timeoutMs = 15000, ...rest } = init;
109
+ const ac = new AbortController();
110
+ const id = setTimeout(() => ac.abort(), timeoutMs);
111
+ try {
112
+ return await fetch(input, { ...rest, signal: ac.signal });
113
+ }
114
+ finally {
115
+ clearTimeout(id);
116
+ }
117
+ }
118
+ // -------- PKCE (RFC 7636) --------
119
+ const PKCE_VERIFIER_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
120
+ export function generateCodeVerifier(length = 64) {
121
+ const len = Math.min(Math.max(length, 43), 128);
122
+ const alphabet = PKCE_VERIFIER_CHARS;
123
+ const alphabetLength = alphabet.length;
124
+ const maxValue = Math.floor(256 / alphabetLength) * alphabetLength;
125
+ const result = [];
126
+ while (result.length < len) {
127
+ const randomBytes = getRandomBytes(len - result.length);
128
+ for (const byte of randomBytes) {
129
+ if (byte >= maxValue)
130
+ continue; // Skip to avoid modulo bias.
131
+ result.push(alphabet.charAt(byte % alphabetLength));
132
+ if (result.length === len)
133
+ break;
134
+ }
135
+ }
136
+ return result.join('');
137
+ }
138
+ /** S256 challenge for a verifier. */
139
+ export async function computeCodeChallengeS256(codeVerifier) {
140
+ const enc = new TextEncoder();
141
+ const subtle = await getSubtleCrypto();
142
+ const hash = await subtle.digest('SHA-256', enc.encode(codeVerifier));
143
+ return base64Url(hash);
144
+ }
145
+ /** Build an authorization URL with PKCE (S256). */
146
+ export async function buildAuthorizeUrl(cfg, opts) {
147
+ let codeVerifier;
148
+ let codeChallenge;
149
+ if (opts.codeVerifier && opts.codeChallenge) {
150
+ const expectedChallenge = await computeCodeChallengeS256(opts.codeVerifier);
151
+ if (expectedChallenge !== opts.codeChallenge) {
152
+ throw new OAuth2Error('Provided codeChallenge does not match codeVerifier', 'INVALID_PKCE_PAIR');
153
+ }
154
+ codeVerifier = opts.codeVerifier;
155
+ codeChallenge = opts.codeChallenge;
156
+ }
157
+ else if (opts.codeVerifier) {
158
+ codeVerifier = opts.codeVerifier;
159
+ codeChallenge = await computeCodeChallengeS256(codeVerifier);
160
+ }
161
+ else if (opts.codeChallenge) {
162
+ throw new OAuth2Error('codeVerifier is required when providing codeChallenge', 'INVALID_PKCE_PAIR');
163
+ }
164
+ else {
165
+ codeVerifier = generateCodeVerifier();
166
+ codeChallenge = await computeCodeChallengeS256(codeVerifier);
167
+ }
168
+ const u = new URL(cfg.authEndpoint);
169
+ const params = {
170
+ response_type: 'code',
171
+ client_id: cfg.clientId,
172
+ redirect_uri: cfg.redirectUri,
173
+ scope: opts.scopes.map(String).join(' '),
174
+ code_challenge: codeChallenge,
175
+ code_challenge_method: 'S256',
176
+ };
177
+ if (opts.state)
178
+ params.state = opts.state;
179
+ if (opts.prompt)
180
+ params.prompt = opts.prompt;
181
+ if (opts.extras) {
182
+ for (const [k, v] of Object.entries(opts.extras))
183
+ if (v != null)
184
+ params[k] = String(v);
185
+ }
186
+ for (const [k, v] of Object.entries(params))
187
+ u.searchParams.set(k, v);
188
+ return { url: u.toString(), codeVerifier, codeChallenge };
189
+ }
190
+ // -------- Bearer Token Helper (RFC 6750) --------
191
+ export class BearerToken {
192
+ accessToken;
193
+ tokenType;
194
+ refreshToken;
195
+ expiresAt; // epoch ms
196
+ constructor(init) {
197
+ this.accessToken = init.accessToken;
198
+ this.tokenType = init.tokenType;
199
+ this.refreshToken = init.refreshToken;
200
+ this.expiresAt = init.expiresAt;
201
+ }
202
+ static fromOAuthResponse(res, nowMs = Date.now(), skewSeconds = 60) {
203
+ validateTokenResponse(res);
204
+ const expiresAt = typeof res.expires_in === 'number'
205
+ ? nowMs + Math.max(0, (res.expires_in - Math.max(0, skewSeconds)) * 1000)
206
+ : undefined;
207
+ return new BearerToken({
208
+ accessToken: res.access_token,
209
+ tokenType: 'Bearer',
210
+ refreshToken: res.refresh_token,
211
+ expiresAt,
212
+ });
213
+ }
214
+ isExpired(nowMs = Date.now()) {
215
+ if (this.expiresAt == null)
216
+ return false; // unknown β†’ treat as non-expiring
217
+ return nowMs >= this.expiresAt;
218
+ }
219
+ willExpireWithin(seconds, nowMs = Date.now()) {
220
+ if (this.expiresAt == null)
221
+ return false;
222
+ return this.expiresAt - nowMs <= seconds * 1000;
223
+ }
224
+ toAuthHeader() {
225
+ return { Authorization: `Bearer ${this.accessToken}` };
226
+ }
227
+ assertUsable(nowMs = Date.now()) {
228
+ if (this.isExpired(nowMs))
229
+ throw new OAuth2Error('Access token expired', 'TOKEN_EXPIRED');
230
+ }
231
+ }
232
+ // -------- Handler (RFC 6749 Β§4.1.3, Β§6) --------
233
+ export class StartGGOAuth2Handler {
234
+ config;
235
+ constructor(config) {
236
+ this.config = config;
237
+ }
238
+ /** Exchange authorization code for tokens (PKCE). */
239
+ async exchangeToken(code, codeVerifier, expectedScopes) {
240
+ const tokenRequest = {
241
+ grant_type: 'authorization_code',
242
+ code,
243
+ redirect_uri: this.config.redirectUri,
244
+ code_verifier: codeVerifier,
245
+ client_id: this.config.clientId,
246
+ };
247
+ const res = await fetchWithTimeout(this.config.tokenEndpoint, {
248
+ method: 'POST',
249
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
250
+ body: formBody(tokenRequest),
251
+ timeoutMs: this.config.fetchTimeoutMs ?? 15000,
252
+ });
253
+ if (!res.ok) {
254
+ const details = await parseJsonSafe(res);
255
+ throw new OAuth2Error('Token exchange failed', 'TOKEN_EXCHANGE_FAILED', details);
256
+ }
257
+ const tokenResponse = (await res.json());
258
+ validateTokenResponse(tokenResponse);
259
+ validateScopesOrAssumePrevious(tokenResponse.scope, expectedScopes.map(String));
260
+ return tokenResponse;
261
+ }
262
+ /** Refresh access token; preserve prior refresh token if server omits rotation. */
263
+ async refreshToken(refreshToken, originalScopes) {
264
+ const refreshRequest = {
265
+ grant_type: 'refresh_token',
266
+ refresh_token: refreshToken,
267
+ client_id: this.config.clientId,
268
+ scope: originalScopes.length ? originalScopes.join(' ') : undefined,
269
+ };
270
+ const res = await fetchWithTimeout(this.config.tokenEndpoint, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
273
+ body: formBody(Object.fromEntries(Object.entries(refreshRequest).filter(([, v]) => v != null))),
274
+ timeoutMs: this.config.fetchTimeoutMs ?? 15000,
275
+ });
276
+ if (!res.ok) {
277
+ const details = await parseJsonSafe(res);
278
+ throw new OAuth2Error('Token refresh failed', 'TOKEN_REFRESH_FAILED', details);
279
+ }
280
+ const tokenResponse = (await res.json());
281
+ validateTokenResponse(tokenResponse);
282
+ validateScopesOrAssumePrevious(tokenResponse.scope, originalScopes.map(String));
283
+ if (!tokenResponse.refresh_token)
284
+ tokenResponse.refresh_token = refreshToken;
285
+ return tokenResponse;
286
+ }
287
+ }
288
+ /** Factory */
289
+ export function createStartGGAuth2Handler(params) {
290
+ return new StartGGOAuth2Handler(params);
291
+ }
@@ -0,0 +1 @@
1
+ export * from './auth/StartGGOAuth2.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './auth/StartGGOAuth2.js';
package/package.json CHANGED
@@ -1,7 +1,20 @@
1
1
  {
2
2
  "name": "startgg-oauth2-full",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
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
+ ],
5
18
  "license": "MIT",
6
19
  "scripts": {
7
20
  "dev:browser": "serve examples/browser -p 5174",
@@ -15,7 +28,7 @@
15
28
  "@types/node": "^22.7.5",
16
29
  "jest": "^29.7.0",
17
30
  "node-fetch": "^3.3.2",
18
- "serve": "^14.2.1",
31
+ "serve": "^14.2.6",
19
32
  "ts-jest": "^29.2.5",
20
33
  "ts-node": "10.9.2",
21
34
  "tsx": "^4.19.0",
@@ -1,18 +0,0 @@
1
- ---
2
- name: Bug report
3
- about: Report a bug
4
- ---
5
-
6
- **Describe the bug**
7
- …
8
-
9
- **Repro**
10
- 1. …
11
- 2. …
12
-
13
- **Expected**
14
- …
15
-
16
- **Env**
17
- - Node:
18
- - Browser:
@@ -1,13 +0,0 @@
1
- ---
2
- name: Feature request
3
- about: Suggest an idea
4
- ---
5
-
6
- **Problem**
7
- …
8
-
9
- **Proposal**
10
- …
11
-
12
- **Alternatives**
13
- …
@@ -1,30 +0,0 @@
1
- # Pull Request
2
-
3
- ## Summary
4
- Explain the change concisely. What does this PR do and why?
5
-
6
- ## Type
7
- - [ ] feat (new feature)
8
- - [ ] fix (bug fix)
9
- - [ ] refactor (no functional change)
10
- - [ ] docs (docs only)
11
- - [ ] test (tests only)
12
- - [ ] chore (infrastructure/CI/build)
13
-
14
- ## Checklist
15
- - [ ] I ran `npm run build` successfully.
16
- - [ ] I ran `npm test` successfully and added/updated tests where behavior changed.
17
- - [ ] I updated README/docs if public API or flows changed.
18
- - [ ] I did not commit secrets/tokens and scrubbed logs.
19
-
20
- ## Breaking Changes
21
- - [ ] Yes β€” describe impact and migration steps below
22
- - [ ] No
23
-
24
- If **Yes**, detail migration notes:
25
-
26
- ## Screenshots / Demos (if applicable)
27
- Paste or link minimal repro or demo outputs.
28
-
29
- ## Related Issues
30
- Link to issues (e.g., #123).
@@ -1,23 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [ main, master ]
6
- pull_request:
7
- branches: [ main, master ]
8
-
9
- jobs:
10
- build-and-test:
11
- runs-on: ubuntu-latest
12
- strategy:
13
- matrix:
14
- node-version: [18.x, 20.x]
15
- steps:
16
- - uses: actions/checkout@v4
17
- - uses: actions/setup-node@v4
18
- with:
19
- node-version: ${{ matrix.node-version }}
20
- cache: 'npm'
21
- - run: npm ci
22
- - run: npm run build
23
- - run: npm test -- --ci
package/CONTRIBUTING.md DELETED
@@ -1,36 +0,0 @@
1
- # Contributing to startgg-oauth2-full
2
-
3
- ## Prerequisites
4
- - Node 18+ and npm 9+
5
- - Git
6
-
7
- ## Layout
8
- - `src` β€” library (TypeScript)
9
- - `__tests__` β€” unit tests
10
- - `examples` β€” browser + node demos (includes local redirect catcher)
11
- - `.github/workflows/ci.yml` β€” CI (build + tests)
12
-
13
- ## Setup
14
- ```bash
15
- npm i
16
- npm run build
17
- npm test
18
- ```
19
-
20
- ## Run examples
21
- ```bash
22
- npm run dev:browser
23
- npm run dev:node
24
- STARTGG_CLIENT_ID=your_id \
25
- STARTGG_AUTH_URL=https://api.start.gg/oauth/authorize \
26
- STARTGG_TOKEN_URL=https://api.start.gg/oauth/token \
27
- npm run dev:node:server
28
- ```
29
-
30
- ## Branch & PR
31
- - Branch naming: `feat/*`, `fix/*`, `docs/*`, `chore/*`
32
- - Before PR:
33
- - [ ] `npm run build` passes
34
- - [ ] `npm test` passes
35
- - [ ] Tests added/updated when changing behavior
36
- - [ ] README/docs updated if API or flows changed
@@ -1,41 +0,0 @@
1
- # Start.gg OAuth Setup
2
-
3
- ## 1) Create an OAuth app
4
- - In Start.gg developer console, create a **Public** OAuth app (PKCE).
5
- - Copy **Client ID**.
6
-
7
- ## 2) Register redirect URIs
8
- Add exact matches you will use:
9
- - Browser demo: `http://localhost:5174/index.html` (or the exact URL you open)
10
- - Node catcher: `http://localhost:3000/callback`
11
-
12
- > Redirect URIs must match exactly (scheme/host/port/path).
13
-
14
- ## 3) Scopes
15
- Common scopes:
16
- - `user.identity`
17
- - `user.email` (requires `user.identity`)
18
- - `tournament.manager`
19
- - `tournament.reporter`
20
-
21
- ## 4) Environment variables (Node catcher)
22
- ```bash
23
- export STARTGG_CLIENT_ID=your_client_id
24
- export STARTGG_AUTH_URL=https://api.start.gg/oauth/authorize
25
- export STARTGG_TOKEN_URL=https://api.start.gg/oauth/token
26
- npm run dev:node:server
27
- ```
28
-
29
- ## 5) Test the flow
30
- 1. Ensure `http://localhost:3000/callback` is registered.
31
- 2. Run the server script above.
32
- 3. Browser opens Start.gg consent.
33
- 4. Approve; you’re redirected to `/callback`.
34
- 5. Terminal prints masked tokens + `Authorization` header.
35
-
36
- ## 6) Production notes
37
- - Always HTTPS for redirects.
38
- - Persist and validate `state` (CSRF).
39
- - Keep `code_verifier` private (session/server).
40
- - Never log raw tokens in prod.
41
- - Plan for refresh token storage/rotation.