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.
Files changed (53) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +18 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +13 -0
  3. package/.github/pull_request_template.md +30 -0
  4. package/.github/workflows/ci.yml +23 -0
  5. package/CONTRIBUTING.md +36 -0
  6. package/LICENSE +21 -0
  7. package/README.md +261 -0
  8. package/STARTGG_OAUTH_SETUP.md +41 -0
  9. package/__tests__/authorize-url.test.ts +62 -0
  10. package/__tests__/bearer-token.test.ts +21 -0
  11. package/__tests__/handler.test.ts +111 -0
  12. package/__tests__/pkce.test.ts +17 -0
  13. package/examples/browser/README.md +20 -0
  14. package/examples/browser/index.html +55 -0
  15. package/examples/browser/package.json +17 -0
  16. package/examples/browser/src/main.ts +105 -0
  17. package/examples/browser/tsconfig.json +11 -0
  18. package/examples/browser/vite.config.ts +8 -0
  19. package/examples/discordjs/.env.example +9 -0
  20. package/examples/discordjs/README.md +36 -0
  21. package/examples/discordjs/package.json +23 -0
  22. package/examples/discordjs/src/bot.ts +202 -0
  23. package/examples/discordjs/tsconfig.json +12 -0
  24. package/examples/nextjs/.env.example +7 -0
  25. package/examples/nextjs/README.md +33 -0
  26. package/examples/nextjs/app/api/startgg/auth-url/route.ts +36 -0
  27. package/examples/nextjs/app/api/startgg/callback/route.ts +55 -0
  28. package/examples/nextjs/app/globals.css +48 -0
  29. package/examples/nextjs/app/layout.tsx +15 -0
  30. package/examples/nextjs/app/page.tsx +93 -0
  31. package/examples/nextjs/lib/pendingStore.ts +37 -0
  32. package/examples/nextjs/lib/startgg.ts +28 -0
  33. package/examples/nextjs/next-env.d.ts +5 -0
  34. package/examples/nextjs/next.config.mjs +6 -0
  35. package/examples/nextjs/package.json +25 -0
  36. package/examples/nextjs/tsconfig.json +21 -0
  37. package/examples/node/.env.example +4 -0
  38. package/examples/node/README.md +27 -0
  39. package/examples/node/package.json +17 -0
  40. package/examples/node/src/index.ts +57 -0
  41. package/examples/node/src/server.ts +120 -0
  42. package/examples/node/tsconfig.json +12 -0
  43. package/examples/vite/README.md +22 -0
  44. package/examples/vite/index.html +41 -0
  45. package/examples/vite/package.json +18 -0
  46. package/examples/vite/src/main.ts +38 -0
  47. package/examples/vite/tsconfig.json +11 -0
  48. package/examples/vite/vite.config.ts +8 -0
  49. package/jest.config.ts +15 -0
  50. package/jest.setup.ts +29 -0
  51. package/package.json +24 -0
  52. package/src/auth/StartGGOAuth2.ts +378 -0
  53. package/tsconfig.json +25 -0
@@ -0,0 +1,18 @@
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:
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea
4
+ ---
5
+
6
+ **Problem**
7
+ …
8
+
9
+ **Proposal**
10
+ …
11
+
12
+ **Alternatives**
13
+ …
@@ -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
@@ -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.