startgg-oauth2-full 0.2.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 +14 -5
- 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/.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,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,8 +50,11 @@ 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
|
|
@@ -83,7 +92,7 @@ import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full';
|
|
|
83
92
|
const cfg = {
|
|
84
93
|
clientId: '<client-id>',
|
|
85
94
|
authEndpoint: 'https://api.start.gg/oauth/authorize',
|
|
86
|
-
redirectUri: 'https://your.app/callback',
|
|
95
|
+
redirectUri: 'https://your.app/api/startgg/callback',
|
|
87
96
|
};
|
|
88
97
|
|
|
89
98
|
const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
|
|
@@ -108,7 +117,7 @@ if (state !== sessionStorage.getItem('oauth:state')) throw new Error('State mism
|
|
|
108
117
|
|
|
109
118
|
const handler = createStartGGAuth2Handler({
|
|
110
119
|
clientId: '<client-id>',
|
|
111
|
-
redirectUri: 'https://your.app/callback',
|
|
120
|
+
redirectUri: 'https://your.app/api/startgg/callback',
|
|
112
121
|
authEndpoint: 'https://api.start.gg/oauth/authorize',
|
|
113
122
|
tokenEndpoint: 'https://api.start.gg/oauth/token',
|
|
114
123
|
});
|
|
@@ -249,7 +258,7 @@ startgg-oauth2-full/
|
|
|
249
258
|
## License
|
|
250
259
|
|
|
251
260
|
**MIT License**
|
|
252
|
-
Copyright ©
|
|
261
|
+
Copyright © 2026 0xABADBABE-ops
|
|
253
262
|
|
|
254
263
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
255
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.2.
|
|
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
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
name: Release Publish
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
workflow_dispatch:
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
name: Publish to npm
|
|
11
|
-
if: github.event.release.draft == false && github.event.release.prerelease == false || github.event_name == 'workflow_dispatch'
|
|
12
|
-
runs-on: ubuntu-latest
|
|
13
|
-
permissions:
|
|
14
|
-
contents: read
|
|
15
|
-
packages: write
|
|
16
|
-
steps:
|
|
17
|
-
- name: Checkout
|
|
18
|
-
uses: actions/checkout@v4
|
|
19
|
-
|
|
20
|
-
- name: Setup Node
|
|
21
|
-
uses: actions/setup-node@v4
|
|
22
|
-
with:
|
|
23
|
-
node-version: 20
|
|
24
|
-
registry-url: https://registry.npmjs.org
|
|
25
|
-
cache: npm
|
|
26
|
-
|
|
27
|
-
- name: Install dependencies
|
|
28
|
-
run: npm ci
|
|
29
|
-
|
|
30
|
-
- name: Verify tag matches package version
|
|
31
|
-
if: github.event_name == 'release'
|
|
32
|
-
run: |
|
|
33
|
-
TAG="${{ github.event.release.tag_name }}"
|
|
34
|
-
PKG_VERSION=$(node -p "require('./package.json').version")
|
|
35
|
-
if [ "$TAG" != "v$PKG_VERSION" ]; then
|
|
36
|
-
echo "Release tag $TAG does not match package version $PKG_VERSION"
|
|
37
|
-
exit 1
|
|
38
|
-
fi
|
|
39
|
-
|
|
40
|
-
- name: Build
|
|
41
|
-
run: npm run build
|
|
42
|
-
|
|
43
|
-
- name: Test
|
|
44
|
-
run: npm test -- --ci
|
|
45
|
-
|
|
46
|
-
- name: Publish
|
|
47
|
-
run: npm publish --access public
|
|
48
|
-
env:
|
|
49
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
50
|
-
|
|
51
|
-
- name: Publish to GitHub Packages
|
|
52
|
-
env:
|
|
53
|
-
NODE_AUTH_TOKEN: ${{ github.token }}
|
|
54
|
-
run: |
|
|
55
|
-
ORIGINAL_NAME=$(node -p "require('./package.json').name")
|
|
56
|
-
OWNER_SCOPE=$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')
|
|
57
|
-
SCOPED_NAME="@${OWNER_SCOPE}/startgg-oauth2-full"
|
|
58
|
-
npm pkg set name="$SCOPED_NAME"
|
|
59
|
-
npm publish --registry https://npm.pkg.github.com
|
|
60
|
-
npm pkg set name="$ORIGINAL_NAME"
|
package/AGENTS.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# Repository Guidelines
|
|
2
|
-
|
|
3
|
-
## Project Structure & Module Organization
|
|
4
|
-
- `src/` contains the TypeScript OAuth2 helpers (PKCE generation, token exchange, authorize URL builder).
|
|
5
|
-
- `__tests__/` mirrors the source with Jest specs (e.g., `pkce.test.ts`, `authorize-url.test.ts`).
|
|
6
|
-
- `examples/` includes Node, server, and browser demos; use them to validate end-to-end flows.
|
|
7
|
-
- Root config (`tsconfig.json`, `jest.config.ts`, `package.json`) defines tooling standards—change only with consensus.
|
|
8
|
-
|
|
9
|
-
## Build, Test, and Development Commands
|
|
10
|
-
- `npm run build` compiles TypeScript via `tsc`; run before opening a PR.
|
|
11
|
-
- `npm test` runs the full Jest suite; add `-- pkce` (or similar) to target individual specs.
|
|
12
|
-
- `npm run dev:node`, `npm run dev:node:server`, and `npm run dev:browser` start interactive demos for manual OAuth verification.
|
|
13
|
-
|
|
14
|
-
## Coding Style & Naming Conventions
|
|
15
|
-
- Write modern TypeScript (ES2022 target) with strict null checks enabled in `tsconfig.json`.
|
|
16
|
-
- Use two-space indentation, semicolons, and single quotes to match the existing codebase.
|
|
17
|
-
- Functions and variables use `camelCase`, classes and enums use `PascalCase`, constants use `SCREAMING_SNAKE_CASE` where appropriate.
|
|
18
|
-
- Prefer small, self-documenting helpers; add concise comments only when logic is non-obvious.
|
|
19
|
-
|
|
20
|
-
## Testing Guidelines
|
|
21
|
-
- Jest with `ts-jest` handles unit tests; new behaviour requires corresponding specs in `__tests__/`.
|
|
22
|
-
- Name test files after the module under test (e.g., `foo.test.ts`); keep describe blocks aligned with exported functions.
|
|
23
|
-
- Ensure deterministic randomness in tests (stub RNGs) and cover error cases like PKCE validation failures.
|
|
24
|
-
|
|
25
|
-
## Commit & Pull Request Guidelines
|
|
26
|
-
- Craft commits in the form `type(scope): summary` (examples in history: `fix(pkce): cap verifier length`).
|
|
27
|
-
- Each PR should explain the change, list validation steps (e.g., `npm test` output), and link related issues or tickets.
|
|
28
|
-
- Include screenshots or curl transcripts when work affects interactive flows or API responses.
|
|
29
|
-
|
|
30
|
-
## Security & Configuration Tips
|
|
31
|
-
- PKCE relies on WebCrypto; document fallbacks if new runtimes lack `crypto.subtle`.
|
|
32
|
-
- Never log tokens or verifiers in examples; scrub debug statements before merging.
|
|
33
|
-
- Keep secrets out of version control and reference sample env files instead.
|
|
34
|
-
|
|
35
|
-
## Agent Workflow Checklist
|
|
36
|
-
- Sync `main`, install modules (`npm install`), and confirm `npm run build` passes.
|
|
37
|
-
- Add focused Jest coverage for new logic and ensure `npm test` is green.
|
|
38
|
-
- Update docs (`README.md`, `AGENTS.md`) when behaviour or workflows shift.
|
|
39
|
-
- Capture manual verification steps (demo URLs, curl output) in the PR description.
|
|
40
|
-
|
|
41
|
-
## Release Process
|
|
42
|
-
- Bump `package.json` version and update changelog/notes as needed.
|
|
43
|
-
- Commit and tag in `vX.Y.Z` form, then push the tag (`git push origin vX.Y.Z`).
|
|
44
|
-
- Create a GitHub release using that tag; the `Release Publish` workflow runs automatically (or run it manually via **Run workflow**).
|
|
45
|
-
- Ensure `NPM_TOKEN` is configured as a repository secret with publish rights to npm.
|
|
46
|
-
- The workflow also publishes to GitHub Packages using `GITHUB_TOKEN`; consumers can install via `npm install @${repo_owner}/startgg-oauth2-full`.
|