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,15 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import './globals.css';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'Start.gg OAuth2 × Next.js Demo',
|
|
6
|
+
description: 'Demonstrates PKCE with startgg-oauth2-full inside a Next.js App Router project.',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<body>{children}</body>
|
|
13
|
+
</html>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
type AuthResponse = {
|
|
6
|
+
url: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function HomePage() {
|
|
10
|
+
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
|
|
14
|
+
const clientId = process.env.NEXT_PUBLIC_STARTGG_CLIENT_ID ?? 'unset';
|
|
15
|
+
const redirectUri = process.env.NEXT_PUBLIC_STARTGG_REDIRECT_URI ?? 'http://localhost:3000/api/startgg/callback';
|
|
16
|
+
|
|
17
|
+
async function handleAuthorize() {
|
|
18
|
+
setLoading(true);
|
|
19
|
+
setError(null);
|
|
20
|
+
setAuthUrl(null);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch('/api/startgg/auth-url', { method: 'POST' });
|
|
24
|
+
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
|
25
|
+
const data = (await res.json()) as AuthResponse;
|
|
26
|
+
setAuthUrl(data.url);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
setError(message);
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<main>
|
|
37
|
+
<h1>Start.gg OAuth2 + Next.js</h1>
|
|
38
|
+
<p>
|
|
39
|
+
This demo calls <code>buildAuthorizeUrl</code> on the server, stores the PKCE verifier in memory, and exchanges
|
|
40
|
+
the authorization code in the callback API route. Update <code>.env.local</code> with your Start.gg client
|
|
41
|
+
values before proceeding.
|
|
42
|
+
</p>
|
|
43
|
+
|
|
44
|
+
<section>
|
|
45
|
+
<h2>Environment</h2>
|
|
46
|
+
<ul>
|
|
47
|
+
<li>
|
|
48
|
+
<strong>Client ID:</strong> <code>{clientId}</code>
|
|
49
|
+
</li>
|
|
50
|
+
<li>
|
|
51
|
+
<strong>Redirect URI:</strong> <code>{redirectUri}</code>
|
|
52
|
+
</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section>
|
|
57
|
+
<h2>Authorize</h2>
|
|
58
|
+
<p>
|
|
59
|
+
Click the button to request a Start.gg authorize URL. The link appears below so you can inspect it before
|
|
60
|
+
visiting. After finishing the Start.gg flow you'll be redirected back here and the callback will exchange
|
|
61
|
+
the code and show a success message.
|
|
62
|
+
</p>
|
|
63
|
+
<button onClick={handleAuthorize} disabled={loading}>
|
|
64
|
+
{loading ? 'Generating…' : 'Authorize with Start.gg'}
|
|
65
|
+
</button>
|
|
66
|
+
{error && (
|
|
67
|
+
<p style={{ color: '#f87171', marginTop: '1rem' }}>
|
|
68
|
+
Failed to request authorize URL: <code>{error}</code>
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
71
|
+
{authUrl && (
|
|
72
|
+
<div style={{ marginTop: '1.5rem' }}>
|
|
73
|
+
<p>Open the link below in a new tab:</p>
|
|
74
|
+
<pre>{authUrl}</pre>
|
|
75
|
+
<p>
|
|
76
|
+
<a href={authUrl} target="_blank" rel="noreferrer">
|
|
77
|
+
Launch Start.gg OAuth
|
|
78
|
+
</a>
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<section>
|
|
85
|
+
<h2>Callback Output</h2>
|
|
86
|
+
<p>
|
|
87
|
+
The callback endpoint responds with a JSON summary. Keep an eye on the terminal running <code>npm run dev</code>{' '}
|
|
88
|
+
to see the raw token payload. Do not ship the in-memory verifier store or plaintext token output to production.
|
|
89
|
+
</p>
|
|
90
|
+
</section>
|
|
91
|
+
</main>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { StartGGScope } from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
type PendingEntry = {
|
|
4
|
+
codeVerifier: string;
|
|
5
|
+
scopes: StartGGScope[];
|
|
6
|
+
createdAt: number;
|
|
7
|
+
timeout: NodeJS.Timeout;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const TTL_MS = 10 * 60_000;
|
|
11
|
+
const store = new Map<string, PendingEntry>();
|
|
12
|
+
|
|
13
|
+
export function savePending(state: string, data: { codeVerifier: string; scopes: StartGGScope[] }) {
|
|
14
|
+
clearPending(state);
|
|
15
|
+
|
|
16
|
+
const timeout = setTimeout(() => {
|
|
17
|
+
store.delete(state);
|
|
18
|
+
}, TTL_MS);
|
|
19
|
+
|
|
20
|
+
store.set(state, { ...data, createdAt: Date.now(), timeout });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function consumePending(state: string) {
|
|
24
|
+
const entry = store.get(state);
|
|
25
|
+
if (!entry) return null;
|
|
26
|
+
clearTimeout(entry.timeout);
|
|
27
|
+
store.delete(state);
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clearPending(state: string) {
|
|
32
|
+
const current = store.get(state);
|
|
33
|
+
if (current) {
|
|
34
|
+
clearTimeout(current.timeout);
|
|
35
|
+
store.delete(state);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createStartGGAuth2Handler } from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
function readEnv(name: string, fallback?: string): string {
|
|
4
|
+
const value = process.env[name] ?? fallback;
|
|
5
|
+
if (!value) {
|
|
6
|
+
throw new Error(`[nextjs example] Missing required environment variable: ${name}`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const clientId = readEnv('STARTGG_CLIENT_ID', process.env.NEXT_PUBLIC_STARTGG_CLIENT_ID);
|
|
12
|
+
const authEndpoint = readEnv('STARTGG_AUTH_ENDPOINT', 'https://api.start.gg/oauth/authorize');
|
|
13
|
+
const tokenEndpoint = readEnv('STARTGG_TOKEN_ENDPOINT', 'https://api.start.gg/oauth/token');
|
|
14
|
+
const redirectUri = readEnv('STARTGG_REDIRECT_URI', 'http://localhost:3000/api/startgg/callback');
|
|
15
|
+
|
|
16
|
+
export const startggConfig = {
|
|
17
|
+
clientId,
|
|
18
|
+
authEndpoint,
|
|
19
|
+
tokenEndpoint,
|
|
20
|
+
redirectUri,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const startggHandler = createStartGGAuth2Handler({
|
|
24
|
+
clientId,
|
|
25
|
+
authEndpoint,
|
|
26
|
+
tokenEndpoint,
|
|
27
|
+
redirectUri,
|
|
28
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-nextjs-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"next": "14.2.13",
|
|
13
|
+
"react": "18.2.0",
|
|
14
|
+
"react-dom": "18.2.0",
|
|
15
|
+
"startgg-oauth2-full": "file:../.."
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.7.5",
|
|
19
|
+
"@types/react": "^18.2.45",
|
|
20
|
+
"@types/react-dom": "^18.2.18",
|
|
21
|
+
"eslint": "^8.57.1",
|
|
22
|
+
"eslint-config-next": "14.2.13",
|
|
23
|
+
"typescript": "^5.6.3"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"module": "esnext",
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"jsx": "preserve",
|
|
16
|
+
"incremental": true,
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Node CLI Demo
|
|
2
|
+
|
|
3
|
+
This example demonstrates how to use the Start.gg OAuth helpers from a plain Node.js environment. It includes two scripts:
|
|
4
|
+
|
|
5
|
+
- `npm run dev` — prints an authorize URL and is designed for quick, manual testing.
|
|
6
|
+
- `npm run oauth-server` — hosts a callback server, opens the authorize URL in your browser, exchanges the code automatically, and logs masked tokens.
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
```bash
|
|
10
|
+
cd examples/node
|
|
11
|
+
cp .env.example .env # optional
|
|
12
|
+
npm install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Populate `STARTGG_CLIENT_ID` in `.env` or your shell. Override the endpoints with `STARTGG_AUTH_URL` and `STARTGG_TOKEN_URL` if you target a non-production Start.gg environment. `PORT` controls the callback server for `npm run oauth-server` (default `3000`).
|
|
16
|
+
|
|
17
|
+
## Manual Flow (`npm run dev`)
|
|
18
|
+
1. Run the script to generate an authorize URL.
|
|
19
|
+
2. Visit the URL, approve Start.gg, and capture the `code` from the redirected address.
|
|
20
|
+
3. Paste the code into the placeholder in `src/index.ts` (or extend the script to prompt for it) to exchange tokens.
|
|
21
|
+
|
|
22
|
+
## Browser-Assisted Flow (`npm run oauth-server`)
|
|
23
|
+
1. Run the script; it starts an HTTP listener on `http://localhost:3000/callback`.
|
|
24
|
+
2. Your default browser opens to Start.gg. Complete the OAuth consent.
|
|
25
|
+
3. The callback handler exchanges the code using `exchangeToken`, logs masked credentials, and exits the process.
|
|
26
|
+
|
|
27
|
+
Persist tokens securely when adapting this approach to production services.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-node-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx src/index.ts",
|
|
8
|
+
"oauth-server": "tsx src/server.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"startgg-oauth2-full": "file:../.."
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"tsx": "^4.19.0",
|
|
15
|
+
"typescript": "^5.6.3"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
BearerToken,
|
|
7
|
+
StartGGScope,
|
|
8
|
+
buildAuthorizeUrl,
|
|
9
|
+
createStartGGAuth2Handler,
|
|
10
|
+
} from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
const cfg = {
|
|
14
|
+
clientId: process.env.STARTGG_CLIENT_ID ?? 'YOUR_CLIENT_ID',
|
|
15
|
+
authEndpoint: process.env.STARTGG_AUTH_URL ?? 'https://api.start.gg/oauth/authorize',
|
|
16
|
+
tokenEndpoint: process.env.STARTGG_TOKEN_URL ?? 'https://api.start.gg/oauth/token',
|
|
17
|
+
redirectUri: 'http://localhost:3000/callback',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const { url, codeVerifier } = await buildAuthorizeUrl(cfg, {
|
|
21
|
+
scopes: [StartGGScope.USER_IDENTITY],
|
|
22
|
+
state: randomUUID(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
console.log('\nOpen this URL in your browser to authorize:\n');
|
|
26
|
+
console.log(`${url}\n`);
|
|
27
|
+
|
|
28
|
+
const rl = createInterface({ input, output });
|
|
29
|
+
const code = (await rl.question('Paste the "code" query parameter once redirected: ')).trim();
|
|
30
|
+
rl.close();
|
|
31
|
+
|
|
32
|
+
if (!code) {
|
|
33
|
+
console.error('No code supplied. Exiting.');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
38
|
+
const tokenResponse = await handler.exchangeToken(code, codeVerifier, [StartGGScope.USER_IDENTITY]);
|
|
39
|
+
const bearer = BearerToken.fromOAuthResponse(tokenResponse);
|
|
40
|
+
|
|
41
|
+
const masked = (t?: string) => (t ? `${t.slice(0, 6)}…${t.slice(-4)}` : undefined);
|
|
42
|
+
console.log('\n✅ Token exchange complete');
|
|
43
|
+
console.log('access_token:', masked(tokenResponse.access_token));
|
|
44
|
+
console.log('refresh_token:', masked(tokenResponse.refresh_token));
|
|
45
|
+
console.log('token_type:', tokenResponse.token_type);
|
|
46
|
+
console.log('expires_in:', tokenResponse.expires_in ?? 'n/a');
|
|
47
|
+
console.log('\nAuthorization header:', bearer.toAuthHeader());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
|
|
51
|
+
|
|
52
|
+
if (isMainModule) {
|
|
53
|
+
main().catch(err => {
|
|
54
|
+
console.error(err);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { URL, fileURLToPath } from 'node:url';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
buildAuthorizeUrl,
|
|
7
|
+
createStartGGAuth2Handler,
|
|
8
|
+
StartGGScope,
|
|
9
|
+
BearerToken,
|
|
10
|
+
} from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
11
|
+
|
|
12
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
13
|
+
const REDIRECT = `http://localhost:${PORT}/callback`;
|
|
14
|
+
|
|
15
|
+
const cfg = {
|
|
16
|
+
clientId: process.env.STARTGG_CLIENT_ID ?? 'YOUR_CLIENT_ID',
|
|
17
|
+
authEndpoint: process.env.STARTGG_AUTH_URL ?? 'https://api.start.gg/oauth/authorize',
|
|
18
|
+
tokenEndpoint: process.env.STARTGG_TOKEN_URL ?? 'https://api.start.gg/oauth/token',
|
|
19
|
+
redirectUri: REDIRECT,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const REQUIRED_SCOPES = [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL];
|
|
23
|
+
|
|
24
|
+
function openInBrowser(url: string): void {
|
|
25
|
+
const platform = process.platform;
|
|
26
|
+
try {
|
|
27
|
+
if (platform === 'darwin') spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
28
|
+
else if (platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
|
|
29
|
+
else spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
|
|
30
|
+
} catch {
|
|
31
|
+
console.log('Please open this URL manually:\\n', url);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const state = randomUUID();
|
|
37
|
+
const { url: authorizeUrl, codeVerifier } = await buildAuthorizeUrl(
|
|
38
|
+
{ clientId: cfg.clientId, authEndpoint: cfg.authEndpoint, redirectUri: cfg.redirectUri },
|
|
39
|
+
{ scopes: REQUIRED_SCOPES, state, extras: { access_type: 'offline' } }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const server = http.createServer(async (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const reqUrl = new URL(req.url || '', `http://localhost:${PORT}`);
|
|
45
|
+
if (reqUrl.pathname !== '/callback') {
|
|
46
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
47
|
+
res.end('OK');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const code = reqUrl.searchParams.get('code');
|
|
52
|
+
const gotState = reqUrl.searchParams.get('state');
|
|
53
|
+
|
|
54
|
+
if (!code || !gotState || gotState !== state) {
|
|
55
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
56
|
+
res.end('Invalid OAuth callback (missing/invalid code or state).');
|
|
57
|
+
console.error('Invalid callback:', { code, state: gotState });
|
|
58
|
+
server.close();
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handler = createStartGGAuth2Handler(cfg);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const tokenResponse = await handler.exchangeToken(code, codeVerifier, REQUIRED_SCOPES);
|
|
67
|
+
const bearer = BearerToken.fromOAuthResponse(tokenResponse);
|
|
68
|
+
const masked = (t?: string) => (t ? `${t.slice(0, 6)}…${t.slice(-4)}` : undefined);
|
|
69
|
+
console.log('\\n✅ OAuth2 token exchange successful!\\n');
|
|
70
|
+
console.log('access_token:', masked(tokenResponse.access_token));
|
|
71
|
+
console.log('refresh_token:', masked(tokenResponse.refresh_token));
|
|
72
|
+
console.log('token_type:', tokenResponse.token_type);
|
|
73
|
+
console.log('expires_in:', tokenResponse.expires_in);
|
|
74
|
+
console.log('scope:', tokenResponse.scope ?? '(omitted → unchanged)');
|
|
75
|
+
console.log('\\nAuthorization header:', bearer.toAuthHeader());
|
|
76
|
+
|
|
77
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
78
|
+
res.end(`
|
|
79
|
+
<!doctype html>
|
|
80
|
+
<title>OAuth Success</title>
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
82
|
+
<body style="font-family: system-ui; margin: 2rem;">
|
|
83
|
+
<h1>Success ✅</h1>
|
|
84
|
+
<p>You can close this window and return to the terminal.</p>
|
|
85
|
+
</body>`);
|
|
86
|
+
|
|
87
|
+
server.close(() => process.exit(0));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error('Token exchange failed:', err);
|
|
90
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
91
|
+
res.end(`<h1>Token exchange failed</h1><pre>${String(err)}</pre>`);
|
|
92
|
+
server.close(() => process.exit(1));
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error('Callback error:', err);
|
|
96
|
+
try {
|
|
97
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
98
|
+
res.end('Internal error');
|
|
99
|
+
} catch {}
|
|
100
|
+
server.close(() => process.exit(1));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
server.listen(PORT, () => {
|
|
105
|
+
console.log(`\\nListening on http://localhost:${PORT}`);
|
|
106
|
+
console.log('\\nOpening browser for OAuth authorization…');
|
|
107
|
+
console.log('(If this does not open automatically, paste this URL manually.)\\n');
|
|
108
|
+
console.log(authorizeUrl, '\\n');
|
|
109
|
+
openInBrowser(authorizeUrl);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
|
|
114
|
+
|
|
115
|
+
if (isMainModule) {
|
|
116
|
+
main().catch((e) => {
|
|
117
|
+
console.error('Fatal:', e);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Vite Demo
|
|
2
|
+
|
|
3
|
+
This example shows how to use `startgg-oauth2-full` inside a Vite application to build PKCE-capable authorize URLs.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd examples/vite
|
|
9
|
+
npm install
|
|
10
|
+
npm run dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The dev server opens on `http://localhost:5174`. Provide your Start.gg OAuth client credentials, submit the form, and copy the generated authorize URL into your browser. The console prints the matching `code_verifier` and `code_challenge` for use during the token exchange.
|
|
14
|
+
|
|
15
|
+
## Build & Preview
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm run build
|
|
19
|
+
npm run preview
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`npm run build` produces a production bundle under `dist/`, and `npm run preview` serves it locally for smoke testing.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Start.gg OAuth2 Vite Demo</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<main id="app">
|
|
10
|
+
<h1>Start.gg OAuth2 Vite Demo</h1>
|
|
11
|
+
<form id="auth-form">
|
|
12
|
+
<label>
|
|
13
|
+
Client ID
|
|
14
|
+
<input name="clientId" value="CHANGE_ME" required />
|
|
15
|
+
</label>
|
|
16
|
+
<label>
|
|
17
|
+
Auth Endpoint
|
|
18
|
+
<input
|
|
19
|
+
name="authEndpoint"
|
|
20
|
+
value="https://start.gg/oauth/authorize"
|
|
21
|
+
required
|
|
22
|
+
/>
|
|
23
|
+
</label>
|
|
24
|
+
<label>
|
|
25
|
+
Redirect URI
|
|
26
|
+
<input name="redirectUri" value="http://localhost:5174/callback" required />
|
|
27
|
+
</label>
|
|
28
|
+
<button type="submit">Generate Authorize URL</button>
|
|
29
|
+
</form>
|
|
30
|
+
<section id="output">
|
|
31
|
+
<h2>Authorize URL</h2>
|
|
32
|
+
<pre id="authorize-url">Fill the form and submit.</pre>
|
|
33
|
+
<h2>Code Verifier</h2>
|
|
34
|
+
<pre id="code-verifier">Generated verifier will appear here.</pre>
|
|
35
|
+
<h2>Code Challenge</h2>
|
|
36
|
+
<pre id="code-challenge">Generated challenge will appear here.</pre>
|
|
37
|
+
</section>
|
|
38
|
+
</main>
|
|
39
|
+
<script type="module" src="/src/main.ts"></script>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-vite-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"startgg-oauth2-full": "file:../.."
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "^5.6.3",
|
|
16
|
+
"vite": "^5.4.8"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { buildAuthorizeUrl, StartGGScope } from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
2
|
+
|
|
3
|
+
const form = document.querySelector<HTMLFormElement>('#auth-form');
|
|
4
|
+
const urlOutput = document.querySelector<HTMLPreElement>('#authorize-url');
|
|
5
|
+
const verifierOutput = document.querySelector<HTMLPreElement>('#code-verifier');
|
|
6
|
+
const challengeOutput = document.querySelector<HTMLPreElement>('#code-challenge');
|
|
7
|
+
|
|
8
|
+
if (!form || !urlOutput || !verifierOutput || !challengeOutput) {
|
|
9
|
+
throw new Error('Demo markup not found');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
form.addEventListener('submit', async event => {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
const data = new FormData(form);
|
|
15
|
+
const clientId = String(data.get('clientId') ?? '');
|
|
16
|
+
const authEndpoint = String(data.get('authEndpoint') ?? '');
|
|
17
|
+
const redirectUri = String(data.get('redirectUri') ?? '');
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = await buildAuthorizeUrl(
|
|
21
|
+
{ clientId, authEndpoint, redirectUri },
|
|
22
|
+
{
|
|
23
|
+
scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
|
|
24
|
+
state: crypto.randomUUID(),
|
|
25
|
+
extras: { prompt: 'consent' },
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
urlOutput.textContent = result.url;
|
|
30
|
+
verifierOutput.textContent = result.codeVerifier;
|
|
31
|
+
challengeOutput.textContent = result.codeChallenge;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
34
|
+
urlOutput.textContent = `Failed to build URL: ${message}`;
|
|
35
|
+
verifierOutput.textContent = '—';
|
|
36
|
+
challengeOutput.textContent = '—';
|
|
37
|
+
}
|
|
38
|
+
});
|
package/jest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config } from '@jest/types';
|
|
2
|
+
|
|
3
|
+
const config: Config.InitialOptions = {
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
|
|
7
|
+
},
|
|
8
|
+
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
|
9
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
10
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
|
11
|
+
clearMocks: true,
|
|
12
|
+
coverageDirectory: 'coverage'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default config;
|