startgg-oauth2-full 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.md +18 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +13 -0
- package/.github/pull_request_template.md +30 -0
- package/.github/workflows/ci.yml +23 -0
- package/CONTRIBUTING.md +36 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/STARTGG_OAUTH_SETUP.md +41 -0
- package/__tests__/authorize-url.test.ts +62 -0
- package/__tests__/bearer-token.test.ts +21 -0
- package/__tests__/handler.test.ts +111 -0
- package/__tests__/pkce.test.ts +17 -0
- package/examples/browser/README.md +20 -0
- package/examples/browser/index.html +55 -0
- package/examples/browser/package.json +17 -0
- package/examples/browser/src/main.ts +105 -0
- package/examples/browser/tsconfig.json +11 -0
- package/examples/browser/vite.config.ts +8 -0
- package/examples/discordjs/.env.example +9 -0
- package/examples/discordjs/README.md +36 -0
- package/examples/discordjs/package.json +23 -0
- package/examples/discordjs/src/bot.ts +202 -0
- package/examples/discordjs/tsconfig.json +12 -0
- package/examples/nextjs/.env.example +7 -0
- package/examples/nextjs/README.md +33 -0
- package/examples/nextjs/app/api/startgg/auth-url/route.ts +36 -0
- package/examples/nextjs/app/api/startgg/callback/route.ts +55 -0
- package/examples/nextjs/app/globals.css +48 -0
- package/examples/nextjs/app/layout.tsx +15 -0
- package/examples/nextjs/app/page.tsx +93 -0
- package/examples/nextjs/lib/pendingStore.ts +37 -0
- package/examples/nextjs/lib/startgg.ts +28 -0
- package/examples/nextjs/next-env.d.ts +5 -0
- package/examples/nextjs/next.config.mjs +6 -0
- package/examples/nextjs/package.json +25 -0
- package/examples/nextjs/tsconfig.json +21 -0
- package/examples/node/.env.example +4 -0
- package/examples/node/README.md +27 -0
- package/examples/node/package.json +17 -0
- package/examples/node/src/index.ts +57 -0
- package/examples/node/src/server.ts +120 -0
- package/examples/node/tsconfig.json +12 -0
- package/examples/vite/README.md +22 -0
- package/examples/vite/index.html +41 -0
- package/examples/vite/package.json +18 -0
- package/examples/vite/src/main.ts +38 -0
- package/examples/vite/tsconfig.json +11 -0
- package/examples/vite/vite.config.ts +8 -0
- package/jest.config.ts +15 -0
- package/jest.setup.ts +29 -0
- package/package.json +24 -0
- package/src/auth/StartGGOAuth2.ts +378 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,30 @@
|
|
|
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).
|
|
@@ -0,0 +1,23 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to do so, subject to the
|
|
10
|
+
following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
<!-- Badges -->
|
|
2
|
+
|
|
3
|
+
<p align="left">
|
|
4
|
+
<a href="https://github.com/0xabadbabe-ops/startgg-oauth2-full/actions/workflows/ci.yml">
|
|
5
|
+
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/0xabadbabe-ops/startgg-oauth2-full/ci.yml?branch=main">
|
|
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">
|
|
9
|
+
</a>
|
|
10
|
+
<a href="./LICENSE">
|
|
11
|
+
<img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg">
|
|
12
|
+
</a>
|
|
13
|
+
<img alt="Node" src="https://img.shields.io/badge/node-%3E%3D18.0-brightgreen">
|
|
14
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-blue">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
# StartGG OAuth2 + PKCE Toolkit (TypeScript)
|
|
18
|
+
|
|
19
|
+
Drop-in utilities for **OAuth 2.0 Authorization Code with PKCE** and **Bearer** usage against Start.gg (or any RFC-compliant OAuth2 provider).
|
|
20
|
+
|
|
21
|
+
- ✅ RFCs: 6749 (OAuth2), 6750 (Bearer), 7636 (PKCE)
|
|
22
|
+
- ✅ PKCE S256, high-entropy verifier
|
|
23
|
+
- ✅ `application/x-www-form-urlencoded` token requests
|
|
24
|
+
- ✅ Scope-safe validation (assume unchanged when `scope` omitted)
|
|
25
|
+
- ✅ Robust error surfaces (JSON/text)
|
|
26
|
+
- ✅ Skew-aware expiry helper (`BearerToken`)
|
|
27
|
+
- ✅ Cross-env: Browser + Node (WebCrypto + `fetch`)
|
|
28
|
+
- ✅ **CI**: Jest + TypeScript build on push/PR
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
Cooked for *you* by 0xabadbabe - using a lot of 💜 and few lines of code.
|
|
33
|
+
... with hope tha this would help for any dev struggling with oauth2 start.gg specific.
|
|
34
|
+
|
|
35
|
+
```fish
|
|
36
|
+
startgg-oauth2-full@0.1.0 test
|
|
37
|
+
> jest --runInBand pkce
|
|
38
|
+
|
|
39
|
+
PASS __tests__/pkce.test.ts
|
|
40
|
+
PKCE helpers
|
|
41
|
+
✓ generateCodeVerifier length bounds (4 ms)
|
|
42
|
+
✓ computeCodeChallengeS256 deterministic (3 ms)
|
|
43
|
+
|
|
44
|
+
Test Suites: 1 passed, 1 total
|
|
45
|
+
Tests: 2 passed, 2 total
|
|
46
|
+
Snapshots: 0 total
|
|
47
|
+
Time: 1.121 s, estimated 2 s
|
|
48
|
+
Ran all test suites matching /pkce/i.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm i startgg-oauth2-pkce
|
|
55
|
+
# or copy src/auth/StartGGOAuth2.ts into your project
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
- Browser: `window.crypto.subtle`, `window.crypto.getRandomValues`, `fetch`
|
|
63
|
+
- Node: **18+** (built-in WebCrypto + `fetch`)
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
### Browser (PKCE → Exchange)
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-pkce';
|
|
73
|
+
|
|
74
|
+
const cfg = {
|
|
75
|
+
clientId: '<client-id>',
|
|
76
|
+
authEndpoint: 'https://api.start.gg/oauth/authorize',
|
|
77
|
+
redirectUri: 'https://your.app/callback',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
|
|
81
|
+
scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
|
|
82
|
+
state: crypto.randomUUID(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
sessionStorage.setItem('pkce:verifier', codeVerifier);
|
|
86
|
+
sessionStorage.setItem('oauth:state', '<same-state>');
|
|
87
|
+
location.href = url;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Callback (Exchange + Bearer)
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { createStartGGAuth2Handler, BearerToken, StartGGScope } from 'startgg-oauth2-pkce';
|
|
94
|
+
|
|
95
|
+
const params = new URLSearchParams(location.search);
|
|
96
|
+
const code = params.get('code')!;
|
|
97
|
+
const state = params.get('state')!;
|
|
98
|
+
if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mismatch');
|
|
99
|
+
|
|
100
|
+
const handler = createStartGGAuth2Handler({
|
|
101
|
+
clientId: '<client-id>',
|
|
102
|
+
redirectUri: 'https://your.app/callback',
|
|
103
|
+
authEndpoint: 'https://api.start.gg/oauth/authorize',
|
|
104
|
+
tokenEndpoint: 'https://api.start.gg/oauth/token',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const res = await handler.exchangeToken(code, sessionStorage.getItem('pkce:verifier')!, [
|
|
108
|
+
StartGGScope.USER_IDENTITY,
|
|
109
|
+
StartGGScope.USER_EMAIL,
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const bearer = BearerToken.fromOAuthResponse(res);
|
|
113
|
+
fetch('https://api.start.gg/your-endpoint', { headers: bearer.toAuthHeader() });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Scripts
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm run build # tsc build
|
|
122
|
+
npm test # Jest tests (needs ts-node installed)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Examples ship as their own workspaces—hop into each folder, install once, then use the local scripts:
|
|
126
|
+
|
|
127
|
+
- Browser (Vite): `cd examples/browser && npm install && npm run dev`
|
|
128
|
+
- Node CLI/server: `cd examples/node && npm install && npm run dev`
|
|
129
|
+
- Discord.js bot: `cd examples/discordjs && npm install && npm run dev`
|
|
130
|
+
- Next.js app: `cd examples/nextjs && npm install && npm run dev`
|
|
131
|
+
- Frontend Vite demo: `cd examples/vite && npm install && npm run dev`
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## API (summary)
|
|
136
|
+
|
|
137
|
+
- `generateCodeVerifier(len?: number): string`
|
|
138
|
+
- `computeCodeChallengeS256(verifier: string): Promise<string>`
|
|
139
|
+
- `buildAuthorizeUrl(cfg, opts): Promise<{ url, codeVerifier, codeChallenge }>`
|
|
140
|
+
- `createStartGGAuth2Handler(cfg): StartGGOAuth2Handler`
|
|
141
|
+
- `exchangeToken(code, codeVerifier, expectedScopes)`
|
|
142
|
+
- `refreshToken(refreshToken, originalScopes)`
|
|
143
|
+
- `BearerToken`
|
|
144
|
+
- `fromOAuthResponse(res, nowMs?, skewSeconds?)`
|
|
145
|
+
- `isExpired()`, `willExpireWithin()`, `toAuthHeader()`, `assertUsable()`
|
|
146
|
+
|
|
147
|
+
### Scopes
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
enum StartGGScope {
|
|
151
|
+
USER_IDENTITY = 'user.identity',
|
|
152
|
+
USER_EMAIL = 'user.email',
|
|
153
|
+
TOURNAMENT_MANAGER = 'tournament.manager',
|
|
154
|
+
TOURNAMENT_REPORTER = 'tournament.reporter',
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Scope Semantics
|
|
161
|
+
|
|
162
|
+
- If response **includes** `scope`, it’s validated; missing required → `ScopeValidationError`.
|
|
163
|
+
- If response **omits** `scope`, treat as unchanged (RFC 6749).
|
|
164
|
+
- Refresh: preserve prior `refresh_token` if omitted by server.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Error Model
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
class OAuth2Error extends Error {
|
|
172
|
+
code?: string; // e.g., TOKEN_EXCHANGE_FAILED
|
|
173
|
+
details?: unknown; // parsed JSON or { raw: string }
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Examples
|
|
180
|
+
|
|
181
|
+
- Browser (Vite SPA): `examples/browser/`
|
|
182
|
+
- Node CLI + redirect catcher: `examples/node/`
|
|
183
|
+
- Discord bot (discord.js v14): `examples/discordjs/`
|
|
184
|
+
- Next.js (App Router): `examples/nextjs/`
|
|
185
|
+
- Frontend Vite scaffold: `examples/vite/`
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## CI
|
|
190
|
+
|
|
191
|
+
GitHub Actions runs TypeScript build + Jest on push/PR (Node 18 & 20). See `.github/workflows/ci.yml`.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Repository Tree
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
startgg-oauth2-full/
|
|
199
|
+
├── README.md
|
|
200
|
+
├── AGENTS.md
|
|
201
|
+
├── LICENSE
|
|
202
|
+
├── package.json
|
|
203
|
+
├── tsconfig.json
|
|
204
|
+
├── jest.config.ts
|
|
205
|
+
├── jest.setup.ts
|
|
206
|
+
├── .gitignore
|
|
207
|
+
├── .npmrc
|
|
208
|
+
├── src/
|
|
209
|
+
│ └── auth/
|
|
210
|
+
│ └── StartGGOAuth2.ts
|
|
211
|
+
├── __tests__/
|
|
212
|
+
│ ├── authorize-url.test.ts
|
|
213
|
+
│ ├── bearer-token.test.ts
|
|
214
|
+
│ ├── handler.test.ts
|
|
215
|
+
│ └── pkce.test.ts
|
|
216
|
+
├── examples/
|
|
217
|
+
│ ├── browser/ # Vanilla browser Vite demo
|
|
218
|
+
│ ├── node/ # CLI + local redirect server
|
|
219
|
+
│ ├── discordjs/ # Discord bot OAuth flow
|
|
220
|
+
│ ├── nextjs/ # Next.js App Router example
|
|
221
|
+
│ └── vite/ # Minimal Vite SPA scaffold
|
|
222
|
+
└── .github/
|
|
223
|
+
├── ISSUE_TEMPLATE/
|
|
224
|
+
│ ├── bug_report.md
|
|
225
|
+
│ └── feature_request.md
|
|
226
|
+
└── workflows/
|
|
227
|
+
└── ci.yml
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Security Notes
|
|
233
|
+
|
|
234
|
+
- Use and verify `state`.
|
|
235
|
+
- Keep `code_verifier` private.
|
|
236
|
+
- Never log tokens; always HTTPS.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
**MIT License**
|
|
243
|
+
Copyright © 2025 0xABADBABE-ops
|
|
244
|
+
|
|
245
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
246
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
247
|
+
in the Software without restriction, including without limitation the rights
|
|
248
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
249
|
+
copies of the Software, and to permit persons to do so, subject to the
|
|
250
|
+
following conditions:
|
|
251
|
+
|
|
252
|
+
The above copyright notice and this permission notice shall be included in all
|
|
253
|
+
copies or substantial portions of the Software.
|
|
254
|
+
|
|
255
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
256
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
257
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
258
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
259
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
260
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
261
|
+
SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
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.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { buildAuthorizeUrl, StartGGScope } from '../src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
describe('Authorize URL', () => {
|
|
4
|
+
test('includes required params and scopes', async () => {
|
|
5
|
+
const cfg = {
|
|
6
|
+
clientId: 'abc',
|
|
7
|
+
authEndpoint: 'https://example.com/authorize',
|
|
8
|
+
redirectUri: 'https://app/callback',
|
|
9
|
+
};
|
|
10
|
+
const { url, codeVerifier, codeChallenge } = await buildAuthorizeUrl(cfg, {
|
|
11
|
+
scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
|
|
12
|
+
state: 'xyz',
|
|
13
|
+
prompt: 'consent',
|
|
14
|
+
extras: { access_type: 'offline' },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const u = new URL(url);
|
|
18
|
+
expect(u.searchParams.get('response_type')).toBe('code');
|
|
19
|
+
expect(u.searchParams.get('client_id')).toBe('abc');
|
|
20
|
+
expect(u.searchParams.get('redirect_uri')).toBe('https://app/callback');
|
|
21
|
+
expect(u.searchParams.get('scope')).toContain('user.identity');
|
|
22
|
+
expect(u.searchParams.get('scope')).toContain('user.email');
|
|
23
|
+
expect(u.searchParams.get('state')).toBe('xyz');
|
|
24
|
+
expect(u.searchParams.get('prompt')).toBe('consent');
|
|
25
|
+
expect(u.searchParams.get('access_type')).toBe('offline');
|
|
26
|
+
expect(u.searchParams.get('code_challenge_method')).toBe('S256');
|
|
27
|
+
expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
28
|
+
expect(codeChallenge.length).toBeGreaterThan(20);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('throws when codeChallenge provided without matching codeVerifier', async () => {
|
|
32
|
+
const cfg = {
|
|
33
|
+
clientId: 'abc',
|
|
34
|
+
authEndpoint: 'https://example.com/authorize',
|
|
35
|
+
redirectUri: 'https://app/callback',
|
|
36
|
+
};
|
|
37
|
+
await expect(
|
|
38
|
+
buildAuthorizeUrl(cfg, {
|
|
39
|
+
scopes: [],
|
|
40
|
+
codeChallenge: 'custom-challenge',
|
|
41
|
+
})
|
|
42
|
+
).rejects.toThrow(/codeVerifier is required/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('accepts externally provided PKCE pair when consistent', async () => {
|
|
46
|
+
const cfg = {
|
|
47
|
+
clientId: 'abc',
|
|
48
|
+
authEndpoint: 'https://example.com/authorize',
|
|
49
|
+
redirectUri: 'https://app/callback',
|
|
50
|
+
};
|
|
51
|
+
const verifier = 'test-verifier-1234567890_-=.~';
|
|
52
|
+
const expectedChallenge = 'xPCddgBLJpr4TYXpjK5OM51rAX0xrMyIOiVyy18DPQ8';
|
|
53
|
+
const result = await buildAuthorizeUrl(cfg, {
|
|
54
|
+
scopes: [],
|
|
55
|
+
codeVerifier: verifier,
|
|
56
|
+
codeChallenge: expectedChallenge,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.codeVerifier).toBe(verifier);
|
|
60
|
+
expect(result.codeChallenge).toBe(expectedChallenge);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BearerToken } from '../src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
describe('BearerToken', () => {
|
|
4
|
+
test('applies skew and expiry checks', () => {
|
|
5
|
+
const now = 1_000_000;
|
|
6
|
+
const res = {
|
|
7
|
+
access_token: 'at',
|
|
8
|
+
token_type: 'Bearer',
|
|
9
|
+
expires_in: 120,
|
|
10
|
+
};
|
|
11
|
+
const token = BearerToken.fromOAuthResponse(res as any, now, 60);
|
|
12
|
+
expect(token.isExpired(now)).toBe(false);
|
|
13
|
+
expect(token.willExpireWithin(120, now)).toBe(true);
|
|
14
|
+
expect(token.toAuthHeader()).toEqual({ Authorization: 'Bearer at' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('non-expiring when expires_in absent', () => {
|
|
18
|
+
const token = BearerToken.fromOAuthResponse({ access_token: 'at', token_type: 'Bearer' } as any, 0, 60);
|
|
19
|
+
expect(token.isExpired()).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStartGGAuth2Handler,
|
|
3
|
+
StartGGScope,
|
|
4
|
+
OAuth2Error,
|
|
5
|
+
} from '../src/auth/StartGGOAuth2';
|
|
6
|
+
|
|
7
|
+
describe('StartGGOAuth2Handler', () => {
|
|
8
|
+
const cfg = {
|
|
9
|
+
clientId: 'client',
|
|
10
|
+
redirectUri: 'https://app/callback',
|
|
11
|
+
authEndpoint: 'https://example.com/authorize',
|
|
12
|
+
tokenEndpoint: 'https://example.com/token',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('exchangeToken success with scope present', async () => {
|
|
20
|
+
const mockBody = {
|
|
21
|
+
access_token: 'at',
|
|
22
|
+
token_type: 'Bearer',
|
|
23
|
+
expires_in: 3600,
|
|
24
|
+
scope: 'user.identity user.email',
|
|
25
|
+
refresh_token: 'rt',
|
|
26
|
+
};
|
|
27
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
28
|
+
new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
32
|
+
const res = await handler.exchangeToken('code', 'verifier', [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL]);
|
|
33
|
+
expect(res.access_token).toBe('at');
|
|
34
|
+
expect(res.refresh_token).toBe('rt');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('exchangeToken accepts missing scope (assume unchanged)', async () => {
|
|
38
|
+
const mockBody = {
|
|
39
|
+
access_token: 'at',
|
|
40
|
+
token_type: 'Bearer',
|
|
41
|
+
expires_in: 3600,
|
|
42
|
+
refresh_token: 'rt',
|
|
43
|
+
};
|
|
44
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
45
|
+
new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
49
|
+
const res = await handler.exchangeToken('code', 'verifier', [StartGGScope.USER_IDENTITY]);
|
|
50
|
+
expect(res.access_token).toBe('at');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('exchangeToken fails when token_type not Bearer', async () => {
|
|
54
|
+
const mockBody = {
|
|
55
|
+
access_token: 'at',
|
|
56
|
+
token_type: 'MAC',
|
|
57
|
+
expires_in: 3600,
|
|
58
|
+
scope: 'user.identity',
|
|
59
|
+
};
|
|
60
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
61
|
+
new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
65
|
+
await expect(handler.exchangeToken('code', 'verifier', [StartGGScope.USER_IDENTITY]))
|
|
66
|
+
.rejects.toThrow(OAuth2Error);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('refreshToken preserves previous refresh_token when omitted', async () => {
|
|
70
|
+
const mockBody = {
|
|
71
|
+
access_token: 'at2',
|
|
72
|
+
token_type: 'Bearer',
|
|
73
|
+
expires_in: 3600,
|
|
74
|
+
scope: 'user.identity',
|
|
75
|
+
};
|
|
76
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
77
|
+
new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
81
|
+
const res = await handler.refreshToken('rt-old', [StartGGScope.USER_IDENTITY]);
|
|
82
|
+
expect(res.refresh_token).toBe('rt-old');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('refreshToken throws when scope reduced', async () => {
|
|
86
|
+
const mockBody = {
|
|
87
|
+
access_token: 'at3',
|
|
88
|
+
token_type: 'Bearer',
|
|
89
|
+
scope: 'user.identity',
|
|
90
|
+
};
|
|
91
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
92
|
+
new Response(JSON.stringify(mockBody), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
96
|
+
await expect(
|
|
97
|
+
handler.refreshToken('rt', [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL])
|
|
98
|
+
).rejects.toThrow('Missing required scopes: user.email');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('handles non-JSON error body safely', async () => {
|
|
102
|
+
jest.spyOn(global, 'fetch' as any).mockResolvedValueOnce(
|
|
103
|
+
// @ts-ignore
|
|
104
|
+
new Response('<html>bad request</html>', { status: 400, headers: { 'Content-Type': 'text/html' } })
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
108
|
+
await expect(handler.exchangeToken('code', 'verifier', [StartGGScope.USER_IDENTITY]))
|
|
109
|
+
.rejects.toThrow('Token exchange failed');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { computeCodeChallengeS256, generateCodeVerifier } from '../src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
describe('PKCE helpers', () => {
|
|
4
|
+
test('generateCodeVerifier length bounds', () => {
|
|
5
|
+
expect(generateCodeVerifier(10).length).toBeGreaterThanOrEqual(43);
|
|
6
|
+
expect(generateCodeVerifier(200).length).toBeLessThanOrEqual(128);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('computeCodeChallengeS256 deterministic', async () => {
|
|
10
|
+
const verifier = 'test-verifier-1234567890_-=.~';
|
|
11
|
+
const ch1 = await computeCodeChallengeS256(verifier);
|
|
12
|
+
const ch2 = await computeCodeChallengeS256(verifier);
|
|
13
|
+
expect(ch1).toEqual(ch2);
|
|
14
|
+
expect(typeof ch1).toBe('string');
|
|
15
|
+
expect(ch1.length).toBeGreaterThan(20);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Browser Demo
|
|
2
|
+
|
|
3
|
+
This example runs the Start.gg OAuth helpers in a plain browser application with no framework. It uses Vite for bundling and serves a single page that builds PKCE authorize URLs, displays the verifier/challenge pair, and exchanges the authorization code after Start.gg redirects back.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
```bash
|
|
7
|
+
cd examples/browser
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The dev server opens at `http://localhost:5173`. Fill in your Start.gg OAuth client details and click **Generate Authorize URL**. Inspect the generated verifier/challenge values, open the authorize link in a new tab, and complete the Start.gg flow. When Start.gg redirects back, the page exchanges the code and prints a token summary.
|
|
13
|
+
|
|
14
|
+
## Production Build
|
|
15
|
+
```bash
|
|
16
|
+
npm run build
|
|
17
|
+
npm run preview
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`npm run build` writes static assets to `dist/`, and `npm run preview` serves them locally.
|