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.
- package/README.md +28 -10
- package/dist/auth/StartGGOAuth2.d.ts +84 -0
- package/dist/auth/StartGGOAuth2.js +291 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +15 -2
- 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/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 -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-
|
|
8
|
-
<img alt="npm" src="https://img.shields.io/npm/v/startgg-oauth2-
|
|
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
|
|
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:
|
|
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-
|
|
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-
|
|
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-
|
|
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 Β©
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
|
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.
|
|
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,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).
|
package/.github/workflows/ci.yml
DELETED
|
@@ -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
|
package/STARTGG_OAUTH_SETUP.md
DELETED
|
@@ -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.
|