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,55 @@
|
|
|
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 Browser Demo</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<main>
|
|
10
|
+
<h1>Start.gg OAuth2 (Vanilla Browser)</h1>
|
|
11
|
+
<p>
|
|
12
|
+
This demo uses the <code>startgg-oauth2-full</code> helpers to produce a PKCE authorize URL and exchange the
|
|
13
|
+
resulting authorization code inside a plain browser app.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<form id="config">
|
|
17
|
+
<label>
|
|
18
|
+
Client ID
|
|
19
|
+
<input name="clientId" value="YOUR_CLIENT_ID" required />
|
|
20
|
+
</label>
|
|
21
|
+
<label>
|
|
22
|
+
Auth Endpoint
|
|
23
|
+
<input name="authEndpoint" value="https://api.start.gg/oauth/authorize" required />
|
|
24
|
+
</label>
|
|
25
|
+
<label>
|
|
26
|
+
Token Endpoint
|
|
27
|
+
<input name="tokenEndpoint" value="https://api.start.gg/oauth/token" required />
|
|
28
|
+
</label>
|
|
29
|
+
<label>
|
|
30
|
+
Redirect URI
|
|
31
|
+
<input name="redirectUri" value="http://localhost:5173/" required />
|
|
32
|
+
</label>
|
|
33
|
+
<button type="submit">Generate Authorize URL</button>
|
|
34
|
+
</form>
|
|
35
|
+
|
|
36
|
+
<section>
|
|
37
|
+
<h2>Generated Values</h2>
|
|
38
|
+
<h3>Authorize URL</h3>
|
|
39
|
+
<pre id="output-url">Fill the form to generate a URL.</pre>
|
|
40
|
+
<h3>Verifier</h3>
|
|
41
|
+
<pre id="output-verifier">Verifier will be shown here.</pre>
|
|
42
|
+
<h3>Challenge</h3>
|
|
43
|
+
<pre id="output-challenge">Challenge will be shown here.</pre>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section>
|
|
47
|
+
<h2>Authorization Callback</h2>
|
|
48
|
+
<p id="callback-status">Waiting for callback…</p>
|
|
49
|
+
<pre id="token-preview">Token preview appears here after callback.</pre>
|
|
50
|
+
</section>
|
|
51
|
+
</main>
|
|
52
|
+
|
|
53
|
+
<script type="module" src="/src/main.ts"></script>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-browser-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"startgg-oauth2-full": "file:../.."
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.6.3",
|
|
15
|
+
"vite": "^5.4.8"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BearerToken,
|
|
3
|
+
StartGGScope,
|
|
4
|
+
buildAuthorizeUrl,
|
|
5
|
+
createStartGGAuth2Handler,
|
|
6
|
+
} from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
7
|
+
|
|
8
|
+
const form = document.querySelector<HTMLFormElement>('#config')!;
|
|
9
|
+
const outputUrl = document.querySelector<HTMLPreElement>('#output-url')!;
|
|
10
|
+
const outputVerifier = document.querySelector<HTMLPreElement>('#output-verifier')!;
|
|
11
|
+
const outputChallenge = document.querySelector<HTMLPreElement>('#output-challenge')!;
|
|
12
|
+
const callbackStatus = document.querySelector<HTMLParagraphElement>('#callback-status')!;
|
|
13
|
+
const tokenPreview = document.querySelector<HTMLPreElement>('#token-preview')!;
|
|
14
|
+
|
|
15
|
+
let lastVerifier: string | null = null;
|
|
16
|
+
let lastConfig: {
|
|
17
|
+
clientId: string;
|
|
18
|
+
authEndpoint: string;
|
|
19
|
+
tokenEndpoint: string;
|
|
20
|
+
redirectUri: string;
|
|
21
|
+
} | null = null;
|
|
22
|
+
|
|
23
|
+
form.addEventListener('submit', async event => {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
const data = new FormData(form);
|
|
26
|
+
|
|
27
|
+
const cfg = {
|
|
28
|
+
clientId: String(data.get('clientId') ?? ''),
|
|
29
|
+
authEndpoint: String(data.get('authEndpoint') ?? ''),
|
|
30
|
+
tokenEndpoint: String(data.get('tokenEndpoint') ?? ''),
|
|
31
|
+
redirectUri: String(data.get('redirectUri') ?? ''),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const { url, codeVerifier, codeChallenge } = await buildAuthorizeUrl(cfg, {
|
|
36
|
+
scopes: [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL],
|
|
37
|
+
state: crypto.randomUUID(),
|
|
38
|
+
prompt: 'consent',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
lastVerifier = codeVerifier;
|
|
42
|
+
lastConfig = cfg;
|
|
43
|
+
|
|
44
|
+
sessionStorage.setItem('startgg:lastVerifier', codeVerifier);
|
|
45
|
+
sessionStorage.setItem('startgg:lastConfig', JSON.stringify(cfg));
|
|
46
|
+
|
|
47
|
+
outputUrl.textContent = url;
|
|
48
|
+
outputVerifier.textContent = codeVerifier;
|
|
49
|
+
outputChallenge.textContent = codeChallenge;
|
|
50
|
+
callbackStatus.textContent = 'Waiting for authorization code…';
|
|
51
|
+
tokenPreview.textContent = 'Token preview appears here after callback.';
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
outputUrl.textContent = `Failed to generate authorize URL: ${message}`;
|
|
55
|
+
outputVerifier.textContent = '—';
|
|
56
|
+
outputChallenge.textContent = '—';
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function handleCallbackIfPresent() {
|
|
61
|
+
const params = new URLSearchParams(window.location.search);
|
|
62
|
+
const code = params.get('code');
|
|
63
|
+
const state = params.get('state');
|
|
64
|
+
if (!code || !state) return;
|
|
65
|
+
|
|
66
|
+
if (!lastVerifier || !lastConfig) {
|
|
67
|
+
lastVerifier = sessionStorage.getItem('startgg:lastVerifier');
|
|
68
|
+
const stored = sessionStorage.getItem('startgg:lastConfig');
|
|
69
|
+
lastConfig = stored ? JSON.parse(stored) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!lastVerifier || !lastConfig) {
|
|
73
|
+
callbackStatus.textContent = 'Missing verifier/config in session storage; restart the flow.';
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const handler = createStartGGAuth2Handler(lastConfig);
|
|
79
|
+
const tokenResponse = await handler.exchangeToken(code, lastVerifier, [
|
|
80
|
+
StartGGScope.USER_IDENTITY,
|
|
81
|
+
StartGGScope.USER_EMAIL,
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const bearer = BearerToken.fromOAuthResponse(tokenResponse);
|
|
85
|
+
callbackStatus.textContent = 'Authorization complete!';
|
|
86
|
+
tokenPreview.textContent = JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
accessTokenPreview: `${bearer.accessToken.slice(0, 8)}…`,
|
|
89
|
+
expiresIn: tokenResponse.expires_in ?? null,
|
|
90
|
+
scope: tokenResponse.scope ?? null,
|
|
91
|
+
refreshTokenPresent: Boolean(tokenResponse.refresh_token),
|
|
92
|
+
},
|
|
93
|
+
null,
|
|
94
|
+
2
|
|
95
|
+
);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error('[browser example] Token exchange failed', error);
|
|
98
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
+
callbackStatus.textContent = `Token exchange failed: ${message}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
handleCallbackIfPresent().catch(err => {
|
|
104
|
+
console.error('[browser example] Failed to process callback', err);
|
|
105
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN
|
|
2
|
+
DISCORD_CLIENT_ID=YOUR_DISCORD_APPLICATION_ID
|
|
3
|
+
# Optional: restrict slash command registration to a single guild for faster propagation.
|
|
4
|
+
# DISCORD_GUILD_ID=YOUR_TEST_GUILD_ID
|
|
5
|
+
|
|
6
|
+
STARTGG_CLIENT_ID=YOUR_STARTGG_CLIENT_ID
|
|
7
|
+
# STARTGG_AUTH_ENDPOINT=https://api.start.gg/oauth/authorize
|
|
8
|
+
# STARTGG_TOKEN_ENDPOINT=https://api.start.gg/oauth/token
|
|
9
|
+
STARTGG_REDIRECT_URI=http://localhost:5175/oauth/callback
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Discord.js Bot Demo
|
|
2
|
+
|
|
3
|
+
This example shows how to generate Start.gg OAuth authorize links and exchange tokens from a Discord slash command using `discord.js` v14.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
- Node.js 18+ (for global `fetch` and WebCrypto).
|
|
7
|
+
- A Discord application with a bot token and the `applications.commands` scope.
|
|
8
|
+
- A Start.gg OAuth client configured with a redirect URI like `http://localhost:5175/oauth/callback`.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
```bash
|
|
12
|
+
cd examples/discordjs
|
|
13
|
+
cp .env.example .env
|
|
14
|
+
# Edit .env with your Discord IDs and Start.gg client info
|
|
15
|
+
npm install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Required `.env` values:
|
|
19
|
+
- `DISCORD_TOKEN` – bot token.
|
|
20
|
+
- `DISCORD_CLIENT_ID` – application/client ID.
|
|
21
|
+
- `STARTGG_CLIENT_ID` – Start.gg OAuth client ID.
|
|
22
|
+
- Optional: `DISCORD_GUILD_ID` for faster guild-only slash command registration.
|
|
23
|
+
- Optional overrides: `STARTGG_AUTH_ENDPOINT`, `STARTGG_TOKEN_ENDPOINT`, `STARTGG_REDIRECT_URI`.
|
|
24
|
+
|
|
25
|
+
## Running the Bot
|
|
26
|
+
```bash
|
|
27
|
+
npm run dev
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The script registers the `/startgg-auth` command, logs the bot in, and starts an Express callback server that listens on the redirect URI's port/path (defaults to `http://localhost:5175/oauth/callback`).
|
|
31
|
+
|
|
32
|
+
1. Invoke `/startgg-auth` in a guild where the bot is present.
|
|
33
|
+
2. Follow the generated authorize link; after approving Start.gg, the callback exchanges the code for tokens.
|
|
34
|
+
3. The bot logs the full token response and DMs you a confirmation with truncated credentials for demonstration.
|
|
35
|
+
|
|
36
|
+
Store tokens securely in real applications—persist them in encrypted storage instead of sending them via DM.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "startgg-oauth2-discordjs-example",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx watch src/bot.ts",
|
|
8
|
+
"start": "tsx src/bot.ts",
|
|
9
|
+
"build": "tsc --outDir dist --project tsconfig.json && node dist/bot.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"discord.js": "^14.15.3",
|
|
13
|
+
"dotenv": "^16.4.5",
|
|
14
|
+
"express": "^4.19.2",
|
|
15
|
+
"startgg-oauth2-full": "file:../.."
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/express": "^4.17.21",
|
|
19
|
+
"@types/node": "^22.7.5",
|
|
20
|
+
"tsx": "^4.19.0",
|
|
21
|
+
"typescript": "^5.6.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js';
|
|
5
|
+
import {
|
|
6
|
+
BearerToken,
|
|
7
|
+
StartGGScope,
|
|
8
|
+
buildAuthorizeUrl,
|
|
9
|
+
createStartGGAuth2Handler,
|
|
10
|
+
} from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
11
|
+
|
|
12
|
+
type PendingAuth = {
|
|
13
|
+
userId: string;
|
|
14
|
+
codeVerifier: string;
|
|
15
|
+
scopes: StartGGScope[];
|
|
16
|
+
timer: NodeJS.Timeout;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const pendingAuthorizations = new Map<string, PendingAuth>();
|
|
20
|
+
const AUTH_TIMEOUT_MS = 10 * 60_000;
|
|
21
|
+
|
|
22
|
+
const requiredEnv = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'STARTGG_CLIENT_ID'] as const;
|
|
23
|
+
const missing = requiredEnv.filter(key => !process.env[key]);
|
|
24
|
+
if (missing.length) {
|
|
25
|
+
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DISCORD_TOKEN = process.env.DISCORD_TOKEN!;
|
|
29
|
+
const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!;
|
|
30
|
+
const DISCORD_GUILD_ID = process.env.DISCORD_GUILD_ID;
|
|
31
|
+
|
|
32
|
+
const startggConfig = {
|
|
33
|
+
clientId: process.env.STARTGG_CLIENT_ID!,
|
|
34
|
+
authEndpoint: process.env.STARTGG_AUTH_ENDPOINT ?? 'https://api.start.gg/oauth/authorize',
|
|
35
|
+
tokenEndpoint: process.env.STARTGG_TOKEN_ENDPOINT ?? 'https://api.start.gg/oauth/token',
|
|
36
|
+
redirectUri: process.env.STARTGG_REDIRECT_URI ?? 'http://localhost:5175/oauth/callback',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const redirectUrl = new URL(startggConfig.redirectUri);
|
|
40
|
+
if (redirectUrl.protocol !== 'http:') {
|
|
41
|
+
console.warn(
|
|
42
|
+
`[discordjs example] Redirect URI ${startggConfig.redirectUri} is non-HTTP; ` +
|
|
43
|
+
'this demo only spins up an HTTP callback server. Adjust as needed.'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const callbackPath = redirectUrl.pathname || '/';
|
|
48
|
+
const callbackPort = Number(redirectUrl.port || 5175);
|
|
49
|
+
|
|
50
|
+
const startggHandler = createStartGGAuth2Handler({
|
|
51
|
+
clientId: startggConfig.clientId,
|
|
52
|
+
redirectUri: startggConfig.redirectUri,
|
|
53
|
+
authEndpoint: startggConfig.authEndpoint,
|
|
54
|
+
tokenEndpoint: startggConfig.tokenEndpoint,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const client = new Client({
|
|
58
|
+
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages],
|
|
59
|
+
partials: [Partials.Channel],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
client.once('ready', readyClient => {
|
|
63
|
+
console.log(`[discordjs example] Logged in as ${readyClient.user.tag}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const scopes = [StartGGScope.USER_IDENTITY, StartGGScope.USER_EMAIL];
|
|
67
|
+
|
|
68
|
+
async function registerSlashCommand(): Promise<void> {
|
|
69
|
+
const command = new SlashCommandBuilder()
|
|
70
|
+
.setName('startgg-auth')
|
|
71
|
+
.setDescription('Generate a Start.gg OAuth authorize URL with PKCE support.')
|
|
72
|
+
.setDMPermission(false);
|
|
73
|
+
|
|
74
|
+
const rest = new REST({ version: '10' }).setToken(DISCORD_TOKEN);
|
|
75
|
+
if (DISCORD_GUILD_ID) {
|
|
76
|
+
await rest.put(Routes.applicationGuildCommands(DISCORD_CLIENT_ID, DISCORD_GUILD_ID), {
|
|
77
|
+
body: [command.toJSON()],
|
|
78
|
+
});
|
|
79
|
+
console.log(
|
|
80
|
+
`[discordjs example] Registered /startgg-auth for guild ${DISCORD_GUILD_ID}. ` +
|
|
81
|
+
'Guild commands update instantly.'
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
await rest.put(Routes.applicationCommands(DISCORD_CLIENT_ID), {
|
|
85
|
+
body: [command.toJSON()],
|
|
86
|
+
});
|
|
87
|
+
console.log('[discordjs example] Registered /startgg-auth globally. Propagation may take up to an hour.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
client.on('interactionCreate', async interaction => {
|
|
92
|
+
if (!interaction.isChatInputCommand() || interaction.commandName !== 'startgg-auth') return;
|
|
93
|
+
|
|
94
|
+
await interaction.deferReply({ ephemeral: true });
|
|
95
|
+
|
|
96
|
+
const state = `${interaction.user.id}-${randomUUID()}`;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const { url, codeVerifier } = await buildAuthorizeUrl(
|
|
100
|
+
{
|
|
101
|
+
clientId: startggConfig.clientId,
|
|
102
|
+
authEndpoint: startggConfig.authEndpoint,
|
|
103
|
+
redirectUri: startggConfig.redirectUri,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
scopes,
|
|
107
|
+
state,
|
|
108
|
+
prompt: 'consent',
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
pendingAuthorizations.delete(state);
|
|
114
|
+
}, AUTH_TIMEOUT_MS);
|
|
115
|
+
|
|
116
|
+
pendingAuthorizations.set(state, {
|
|
117
|
+
userId: interaction.user.id,
|
|
118
|
+
codeVerifier,
|
|
119
|
+
scopes,
|
|
120
|
+
timer,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await interaction.editReply({
|
|
124
|
+
content: [
|
|
125
|
+
'Authorize Start.gg with PKCE:',
|
|
126
|
+
url,
|
|
127
|
+
'',
|
|
128
|
+
`This link is tied to your Discord user and expires in ${Math.round(AUTH_TIMEOUT_MS / 60000)} minutes.`,
|
|
129
|
+
'After authorizing, the bot will DM you once the callback succeeds.',
|
|
130
|
+
].join('\n'),
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error('[discordjs example] Failed to build authorize URL', error);
|
|
134
|
+
pendingAuthorizations.delete(state);
|
|
135
|
+
await interaction.editReply('Failed to generate authorize URL. Check the bot logs for details.');
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const app = express();
|
|
140
|
+
|
|
141
|
+
app.get(callbackPath, async (req, res) => {
|
|
142
|
+
const { state, code, error, error_description: errorDescription } = req.query as Record<string, string | undefined>;
|
|
143
|
+
|
|
144
|
+
if (error) {
|
|
145
|
+
console.warn(`[discordjs example] Authorization error: ${error} ${errorDescription ?? ''}`.trim());
|
|
146
|
+
res.status(400).send('Authorization failed. Check the bot logs for details.');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!state || !code) {
|
|
151
|
+
res.status(400).send('Missing code or state.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pending = pendingAuthorizations.get(state);
|
|
156
|
+
if (!pending) {
|
|
157
|
+
res.status(400).send('State no longer valid. Start a new authorization from Discord.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
clearTimeout(pending.timer);
|
|
162
|
+
pendingAuthorizations.delete(state);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const tokenResponse = await startggHandler.exchangeToken(code, pending.codeVerifier, pending.scopes);
|
|
166
|
+
const bearer = BearerToken.fromOAuthResponse(tokenResponse);
|
|
167
|
+
console.log('[discordjs example] OAuth success for user', pending.userId, tokenResponse);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const user = await client.users.fetch(pending.userId);
|
|
171
|
+
const expires = bearer.expiresAt ? new Date(bearer.expiresAt).toISOString() : 'unknown';
|
|
172
|
+
await user.send(
|
|
173
|
+
[
|
|
174
|
+
'✅ Start.gg authorization complete!',
|
|
175
|
+
`Access token (truncated): ${bearer.accessToken.slice(0, 8)}…`,
|
|
176
|
+
`Expires at: ${expires}`,
|
|
177
|
+
'Check the bot logs for the full token payload and handle it securely.',
|
|
178
|
+
].join('\n')
|
|
179
|
+
);
|
|
180
|
+
} catch (dmError) {
|
|
181
|
+
console.warn('[discordjs example] Unable to DM user with result', dmError);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
res.send('Authorization complete! Return to Discord to continue.');
|
|
185
|
+
} catch (exchangeError) {
|
|
186
|
+
console.error('[discordjs example] Token exchange failed', exchangeError);
|
|
187
|
+
res.status(500).send('Token exchange failed. Check the bot logs for details.');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
async function main(): Promise<void> {
|
|
192
|
+
await registerSlashCommand();
|
|
193
|
+
await client.login(DISCORD_TOKEN);
|
|
194
|
+
app.listen(callbackPort, () => {
|
|
195
|
+
console.log(`[discordjs example] Listening for Start.gg callbacks on http://localhost:${callbackPort}${callbackPath}`);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
main().catch(err => {
|
|
200
|
+
console.error('[discordjs example] Fatal startup error', err);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
STARTGG_CLIENT_ID=YOUR_STARTGG_CLIENT_ID
|
|
2
|
+
# STARTGG_AUTH_ENDPOINT=https://api.start.gg/oauth/authorize
|
|
3
|
+
# STARTGG_TOKEN_ENDPOINT=https://api.start.gg/oauth/token
|
|
4
|
+
STARTGG_REDIRECT_URI=http://localhost:3000/api/startgg/callback
|
|
5
|
+
|
|
6
|
+
NEXT_PUBLIC_STARTGG_CLIENT_ID=YOUR_STARTGG_CLIENT_ID
|
|
7
|
+
NEXT_PUBLIC_STARTGG_REDIRECT_URI=http://localhost:3000/api/startgg/callback
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Next.js OAuth Demo
|
|
2
|
+
|
|
3
|
+
This example integrates `startgg-oauth2-full` into a Next.js App Router project. It renders a page that generates a Start.gg authorize URL with PKCE, stores the verifier server-side, and exchanges the authorization code in an API route.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
```bash
|
|
7
|
+
cd examples/nextjs
|
|
8
|
+
cp .env.example .env.local
|
|
9
|
+
# Edit the file with your Start.gg client settings
|
|
10
|
+
npm install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Required values:
|
|
14
|
+
- `STARTGG_CLIENT_ID` – Start.gg OAuth client ID (public).
|
|
15
|
+
- `STARTGG_REDIRECT_URI` – defaults to `http://localhost:3000/api/startgg/callback`.
|
|
16
|
+
- Optional overrides: `STARTGG_AUTH_ENDPOINT`, `STARTGG_TOKEN_ENDPOINT`.
|
|
17
|
+
- `NEXT_PUBLIC_STARTGG_CLIENT_ID` mirrors the client ID for client-side display.
|
|
18
|
+
- `NEXT_PUBLIC_STARTGG_REDIRECT_URI` mirrors the redirect URI for helpful UI messaging.
|
|
19
|
+
|
|
20
|
+
## Development
|
|
21
|
+
```bash
|
|
22
|
+
npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Navigate to `http://localhost:3000`, click **Authorize with Start.gg**, and approve in the Start.gg window. The callback route exchanges the authorization code and displays the token payload for demo purposes.
|
|
26
|
+
|
|
27
|
+
## Production Build
|
|
28
|
+
```bash
|
|
29
|
+
npm run build
|
|
30
|
+
npm start
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`npm run build` compiles the app, and `npm start` serves the production bundle. Adapt the in-memory PKCE store to persistent storage before deploying to serverless environments.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { StartGGScope, buildAuthorizeUrl } from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
4
|
+
import { startggConfig } from '../../../../lib/startgg';
|
|
5
|
+
import { savePending } from '../../../../lib/pendingStore';
|
|
6
|
+
|
|
7
|
+
export async function POST() {
|
|
8
|
+
try {
|
|
9
|
+
const state = randomUUID();
|
|
10
|
+
const scopes = [StartGGScope.USER_IDENTITY];
|
|
11
|
+
|
|
12
|
+
const { url, codeVerifier } = await buildAuthorizeUrl(startggConfig, {
|
|
13
|
+
scopes,
|
|
14
|
+
state,
|
|
15
|
+
prompt: 'consent',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
savePending(state, { codeVerifier, scopes });
|
|
19
|
+
|
|
20
|
+
return NextResponse.json<AuthUrlResponse>({
|
|
21
|
+
url,
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('[nextjs example] Failed to generate authorize URL', error);
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
{
|
|
27
|
+
error: 'Unable to generate authorize URL. Check server logs for details.',
|
|
28
|
+
},
|
|
29
|
+
{ status: 500 }
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AuthUrlResponse = {
|
|
35
|
+
url: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { BearerToken } from 'startgg-oauth2-full/src/auth/StartGGOAuth2';
|
|
3
|
+
import { consumePending } from '../../../../lib/pendingStore';
|
|
4
|
+
import { startggHandler } from '../../../../lib/startgg';
|
|
5
|
+
|
|
6
|
+
export async function GET(request: Request) {
|
|
7
|
+
const url = new URL(request.url);
|
|
8
|
+
const state = url.searchParams.get('state');
|
|
9
|
+
const code = url.searchParams.get('code');
|
|
10
|
+
const error = url.searchParams.get('error');
|
|
11
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
12
|
+
|
|
13
|
+
if (error) {
|
|
14
|
+
console.warn('[nextjs example] Authorization error from Start.gg', error, errorDescription);
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ error, errorDescription: errorDescription ?? null },
|
|
17
|
+
{ status: 400 }
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!state || !code) {
|
|
22
|
+
return NextResponse.json(
|
|
23
|
+
{ error: 'Missing state or code.' },
|
|
24
|
+
{ status: 400 }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pending = consumePending(state);
|
|
29
|
+
if (!pending) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: 'State has expired or is invalid. Generate a new authorize URL.' },
|
|
32
|
+
{ status: 400 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const tokenResponse = await startggHandler.exchangeToken(code, pending.codeVerifier, pending.scopes);
|
|
38
|
+
const bearer = BearerToken.fromOAuthResponse(tokenResponse);
|
|
39
|
+
|
|
40
|
+
console.log('[nextjs example] Token response', tokenResponse);
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({
|
|
43
|
+
message: 'Authorization complete!',
|
|
44
|
+
scope: tokenResponse.scope ?? null,
|
|
45
|
+
expiresIn: tokenResponse.expires_in ?? null,
|
|
46
|
+
accessTokenPreview: `${bearer.accessToken.slice(0, 8)}…`,
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('[nextjs example] Token exchange failed', err);
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{ error: 'Token exchange failed. Inspect server logs for details.' },
|
|
52
|
+
{ status: 500 }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
4
|
+
line-height: 1.6;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 2rem;
|
|
10
|
+
background: #0f172a;
|
|
11
|
+
color: #e2e8f0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
a {
|
|
15
|
+
color: #38bdf8;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
button {
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
border: none;
|
|
21
|
+
background: #38bdf8;
|
|
22
|
+
color: #0f172a;
|
|
23
|
+
padding: 0.75rem 1.5rem;
|
|
24
|
+
border-radius: 999px;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
button:disabled {
|
|
29
|
+
cursor: progress;
|
|
30
|
+
opacity: 0.7;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main {
|
|
34
|
+
max-width: 720px;
|
|
35
|
+
margin: 0 auto;
|
|
36
|
+
background: rgba(15, 23, 42, 0.6);
|
|
37
|
+
border-radius: 16px;
|
|
38
|
+
padding: 2rem;
|
|
39
|
+
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.6);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
pre {
|
|
43
|
+
padding: 1rem;
|
|
44
|
+
background: rgba(15, 23, 42, 0.9);
|
|
45
|
+
border-radius: 12px;
|
|
46
|
+
overflow-x: auto;
|
|
47
|
+
word-break: break-all;
|
|
48
|
+
}
|