startgg-oauth2-full 0.2.0 → 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 +325 -51
- package/dist/auth/StartGGOAuth2.d.ts +85 -0
- package/dist/auth/StartGGOAuth2.js +306 -0
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +63 -22
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -18
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -13
- package/.github/pull_request_template.md +0 -30
- package/.github/workflows/ci.yml +0 -23
- package/.github/workflows/release.yml +0 -60
- package/AGENTS.md +0 -46
- package/CONTRIBUTING.md +0 -36
- package/STARTGG_OAUTH_SETUP.md +0 -41
- package/__tests__/authorize-url.test.ts +0 -62
- package/__tests__/bearer-token.test.ts +0 -21
- package/__tests__/handler.test.ts +0 -111
- package/__tests__/pkce.test.ts +0 -17
- package/examples/browser/README.md +0 -20
- package/examples/browser/index.html +0 -55
- package/examples/browser/package.json +0 -17
- package/examples/browser/src/main.ts +0 -105
- package/examples/browser/tsconfig.json +0 -11
- package/examples/browser/vite.config.ts +0 -8
- package/examples/discordjs/.env.example +0 -9
- package/examples/discordjs/README.md +0 -36
- package/examples/discordjs/package.json +0 -23
- package/examples/discordjs/src/bot.ts +0 -202
- package/examples/discordjs/tsconfig.json +0 -12
- package/examples/nextjs/.env.example +0 -7
- package/examples/nextjs/README.md +0 -33
- package/examples/nextjs/app/api/startgg/auth-url/route.ts +0 -36
- package/examples/nextjs/app/api/startgg/callback/route.ts +0 -55
- package/examples/nextjs/app/globals.css +0 -48
- package/examples/nextjs/app/layout.tsx +0 -15
- package/examples/nextjs/app/page.tsx +0 -93
- package/examples/nextjs/lib/pendingStore.ts +0 -37
- package/examples/nextjs/lib/startgg.ts +0 -28
- package/examples/nextjs/next-env.d.ts +0 -5
- package/examples/nextjs/next.config.mjs +0 -6
- package/examples/nextjs/package.json +0 -25
- package/examples/nextjs/tsconfig.json +0 -21
- package/examples/node/.env.example +0 -4
- package/examples/node/README.md +0 -27
- package/examples/node/package.json +0 -17
- package/examples/node/src/index.ts +0 -57
- package/examples/node/src/server.ts +0 -120
- package/examples/node/tsconfig.json +0 -12
- package/examples/vite/README.md +0 -22
- package/examples/vite/index.html +0 -41
- package/examples/vite/package.json +0 -18
- package/examples/vite/src/main.ts +0 -38
- package/examples/vite/tsconfig.json +0 -11
- package/examples/vite/vite.config.ts +0 -8
- package/jest.config.ts +0 -15
- package/jest.setup.ts +0 -29
- package/src/auth/StartGGOAuth2.ts +0 -378
- package/tsconfig.json +0 -27
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
<a href="https://www.npmjs.com/package/startgg-oauth2-full">
|
|
8
8
|
<img alt="npm" src="https://img.shields.io/npm/v/startgg-oauth2-full">
|
|
9
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">
|
|
12
|
+
</a>
|
|
10
13
|
<a href="./LICENSE">
|
|
11
14
|
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg">
|
|
12
15
|
</a>
|
|
@@ -33,19 +36,26 @@ 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
|
|
37
|
-
|
|
39
|
+
┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:08:50]─[G:main =]
|
|
40
|
+
╰─>$ npm test
|
|
41
|
+
|
|
42
|
+
> startgg-oauth2-full@0.2.2 test
|
|
43
|
+
> jest --runInBand
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
43
50
|
|
|
44
|
-
Test Suites:
|
|
45
|
-
Tests:
|
|
51
|
+
Test Suites: 5 passed, 5 total
|
|
52
|
+
Tests: 28 passed, 28 total
|
|
46
53
|
Snapshots: 0 total
|
|
47
|
-
Time:
|
|
48
|
-
Ran all test suites
|
|
54
|
+
Time: 0.859 s, estimated 1 s
|
|
55
|
+
Ran all test suites.
|
|
56
|
+
┬─[playerone@fedora:~/d/startgg-oauth2-full]─[21:10:09]─[G:main =]
|
|
57
|
+
╰─>$
|
|
58
|
+
[0] 0:fish* "~/d/startgg-oauth2-fu" 21:10 26-wrz-26
|
|
49
59
|
```
|
|
50
60
|
|
|
51
61
|
## Installation
|
|
@@ -73,53 +83,245 @@ npm install @0xabadbabe-ops/startgg-oauth2-full
|
|
|
73
83
|
|
|
74
84
|
---
|
|
75
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
|
+
|
|
76
99
|
## Quick Start
|
|
77
100
|
|
|
78
|
-
###
|
|
101
|
+
### 1. Build the authorize URL (browser or server)
|
|
79
102
|
|
|
80
103
|
```ts
|
|
81
|
-
import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full';
|
|
104
|
+
import { buildAuthorizeUrl, StartGGScope, STARTGG_ENDPOINTS } from 'startgg-oauth2-full';
|
|
82
105
|
|
|
83
106
|
const cfg = {
|
|
84
107
|
clientId: '<client-id>',
|
|
85
|
-
authEndpoint:
|
|
86
|
-
redirectUri: 'https://your.app/callback',
|
|
108
|
+
authEndpoint: STARTGG_ENDPOINTS.authorize,
|
|
109
|
+
redirectUri: 'https://your.app/api/startgg/callback',
|
|
87
110
|
};
|
|
88
111
|
|
|
112
|
+
const state = crypto.randomUUID();
|
|
89
113
|
const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
|
|
90
114
|
scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
|
|
91
|
-
state
|
|
115
|
+
state,
|
|
92
116
|
});
|
|
93
117
|
|
|
118
|
+
// Keep both tied together for the callback (session, or a server-side state store)
|
|
94
119
|
sessionStorage.setItem('pkce:verifier', codeVerifier);
|
|
95
|
-
sessionStorage.setItem('oauth:state',
|
|
120
|
+
sessionStorage.setItem('oauth:state', state);
|
|
96
121
|
location.href = url;
|
|
97
122
|
```
|
|
98
123
|
|
|
99
|
-
###
|
|
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
|
|
100
189
|
|
|
101
190
|
```ts
|
|
102
191
|
import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-full';
|
|
103
192
|
|
|
104
|
-
const
|
|
105
|
-
const code = params.get('code')!;
|
|
106
|
-
const state = params.get('state')!;
|
|
107
|
-
if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mismatch');
|
|
193
|
+
const handler = createStartGGAuth2Handler({ clientId, redirectUri });
|
|
108
194
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Server-Side (Node/Next.js) with Secure State Store
|
|
225
|
+
|
|
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 }),
|
|
114
273
|
});
|
|
115
274
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
275
|
+
const { data } = await response.json();
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Error Handling
|
|
120
279
|
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
};
|
|
123
325
|
```
|
|
124
326
|
|
|
125
327
|
---
|
|
@@ -127,11 +329,12 @@ fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
|
|
|
127
329
|
## Scripts
|
|
128
330
|
|
|
129
331
|
```bash
|
|
130
|
-
npm run build # tsc
|
|
131
|
-
npm test # Jest
|
|
332
|
+
npm run build # compile TypeScript (tsc)
|
|
333
|
+
npm test # full Jest suite (ts-jest)
|
|
334
|
+
npm test -- pkce # target a single spec
|
|
132
335
|
```
|
|
133
336
|
|
|
134
|
-
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:
|
|
135
338
|
|
|
136
339
|
- Browser (Vite): `cd examples/browser && npm install && npm run dev`
|
|
137
340
|
- Node CLI/server: `cd examples/node && npm install && npm run dev`
|
|
@@ -152,6 +355,8 @@ Examples ship as their own workspaces—hop into each folder, install once, then
|
|
|
152
355
|
- `BearerToken`
|
|
153
356
|
- `fromOAuthResponse(res, nowMs?, skewSeconds?)`
|
|
154
357
|
- `isExpired()`, `willExpireWithin()`, `toAuthHeader()`, `assertUsable()`
|
|
358
|
+
- `STARTGG_ENDPOINTS`, `STARTGG_SCOPES`, `STARTGG_GQL_AUTH_HEADER`
|
|
359
|
+
- `isValidStartGGScope(value): boolean`
|
|
155
360
|
|
|
156
361
|
### Scopes
|
|
157
362
|
|
|
@@ -168,7 +373,7 @@ enum StartGGScope {
|
|
|
168
373
|
|
|
169
374
|
## Scope Semantics
|
|
170
375
|
|
|
171
|
-
- If response **includes** `scope`, it
|
|
376
|
+
- If response **includes** `scope`, it's validated; missing required → `ScopeValidationError`.
|
|
172
377
|
- If response **omits** `scope`, treat as unchanged (RFC 6749).
|
|
173
378
|
- Refresh: preserve prior `refresh_token` if omitted by server.
|
|
174
379
|
|
|
@@ -181,17 +386,73 @@ class OAuth2Error extends Error {
|
|
|
181
386
|
code?: string; // e.g., TOKEN_EXCHANGE_FAILED
|
|
182
387
|
details?: unknown; // parsed JSON or { raw: string }
|
|
183
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
|
+
}
|
|
184
394
|
```
|
|
185
395
|
|
|
186
396
|
---
|
|
187
397
|
|
|
188
398
|
## Examples
|
|
189
399
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
195
456
|
|
|
196
457
|
---
|
|
197
458
|
|
|
@@ -207,27 +468,33 @@ GitHub Actions runs TypeScript build + Jest on push/PR (Node 18 & 20). See `.git
|
|
|
207
468
|
startgg-oauth2-full/
|
|
208
469
|
├── README.md
|
|
209
470
|
├── AGENTS.md
|
|
471
|
+
├── CONTRIBUTING.md
|
|
210
472
|
├── LICENSE
|
|
473
|
+
├── STARTGG_OAUTH_SETUP.md # Start.gg app registration & env vars
|
|
474
|
+
├── VERCEL_CONNECT_STARTGG.md # Vercel Connect guide
|
|
211
475
|
├── package.json
|
|
212
476
|
├── tsconfig.json
|
|
213
477
|
├── jest.config.ts
|
|
214
478
|
├── jest.setup.ts
|
|
215
|
-
├── .gitignore
|
|
216
|
-
├── .npmrc
|
|
217
479
|
├── src/
|
|
218
|
-
│
|
|
219
|
-
│
|
|
480
|
+
│ ├── auth/
|
|
481
|
+
│ │ └── StartGGOAuth2.ts # PKCE + handler + BearerToken
|
|
482
|
+
│ ├── constants.ts # STARTGG_ENDPOINTS, scopes
|
|
483
|
+
│ └── index.ts
|
|
220
484
|
├── __tests__/
|
|
221
485
|
│ ├── authorize-url.test.ts
|
|
222
486
|
│ ├── bearer-token.test.ts
|
|
487
|
+
│ ├── constants.test.ts
|
|
223
488
|
│ ├── handler.test.ts
|
|
224
489
|
│ └── pkce.test.ts
|
|
225
490
|
├── examples/
|
|
226
|
-
│ ├── browser/
|
|
227
|
-
│ ├── node/
|
|
228
|
-
│ ├── discordjs/
|
|
229
|
-
│ ├── nextjs/
|
|
230
|
-
│ └── vite/
|
|
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/
|
|
231
498
|
└── .github/
|
|
232
499
|
├── ISSUE_TEMPLATE/
|
|
233
500
|
│ ├── bug_report.md
|
|
@@ -243,13 +510,20 @@ startgg-oauth2-full/
|
|
|
243
510
|
- Use and verify `state`.
|
|
244
511
|
- Keep `code_verifier` private.
|
|
245
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).
|
|
246
520
|
|
|
247
521
|
---
|
|
248
522
|
|
|
249
523
|
## License
|
|
250
524
|
|
|
251
525
|
**MIT License**
|
|
252
|
-
Copyright ©
|
|
526
|
+
Copyright © 2026 0xABADBABE-ops
|
|
253
527
|
|
|
254
528
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
255
529
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
* Per RFC 6749, scope should only be included when requesting a subset of original scopes. */
|
|
76
|
+
refreshToken(refreshToken: string, originalScopes: StartGGScope[]): Promise<OAuth2TokenResponse>;
|
|
77
|
+
}
|
|
78
|
+
/** Factory */
|
|
79
|
+
export declare function createStartGGAuth2Handler(params: {
|
|
80
|
+
clientId: string;
|
|
81
|
+
redirectUri: string;
|
|
82
|
+
authEndpoint?: string;
|
|
83
|
+
tokenEndpoint?: string;
|
|
84
|
+
fetchTimeoutMs?: number;
|
|
85
|
+
}): StartGGOAuth2Handler;
|