added refresh token flow

This commit is contained in:
GW_MC
2026-01-23 18:02:35 +08:00
parent b972f10e60
commit 501198b5a0

View File

@@ -1,19 +1,78 @@
import * as RPC from 'discord-rpc'; import * as RPC from 'discord-rpc';
import axios from 'axios'; import axios from 'axios';
import * as fs from 'fs';
import * as path from 'path';
import { CONFIG } from './config'; import { CONFIG } from './config';
import { VoiceSettingsPayload } from './types'; import { VoiceSettingsPayload } from './types';
interface TokenData {
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix ms timestamp
}
class DiscordService { class DiscordService {
private client: RPC.Client; private client: RPC.Client;
private loggedIn = false; private loggedIn = false;
private isLoginInProgress = false; private isLoginInProgress = false;
private voiceUpdateCallbacks: Array<(d: VoiceSettingsPayload) => void> = []; private voiceUpdateCallbacks: Array<(d: VoiceSettingsPayload) => void> = [];
private static TOKEN_FILE = path.join(__dirname, '../', '.discord-token.json');
constructor() { constructor() {
this.client = new RPC.Client({ transport: 'ipc' }); this.client = new RPC.Client({ transport: 'ipc' });
this.setupEvents(); this.setupEvents();
} }
private saveTokens(data: TokenData): void {
try {
fs.writeFileSync(DiscordService.TOKEN_FILE, JSON.stringify(data, null, 2));
console.log('Tokens saved locally');
} catch (err) {
console.error('Failed to save tokens:', err);
}
}
private loadTokens(): TokenData | null {
try {
if (!fs.existsSync(DiscordService.TOKEN_FILE)) return null;
const txt = fs.readFileSync(DiscordService.TOKEN_FILE, 'utf-8');
return JSON.parse(txt) as TokenData;
} catch (err) {
console.error('Error reading token file:', err);
return null;
}
}
private isTokenValid(tokenData: TokenData): boolean {
return Date.now() < tokenData.expiresAt - 5000; // small buffer
}
private async refreshAccessToken(refreshToken: string): Promise<TokenData | null> {
try {
const params = new URLSearchParams({
client_id: CONFIG.CLIENT_ID,
client_secret: CONFIG.CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const response = await axios.post('https://discord.com/api/oauth2/token', params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
const newTokenData: TokenData = {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
expiresAt: Date.now() + response.data.expires_in * 1000,
};
this.saveTokens(newTokenData);
return newTokenData;
} catch (error) {
console.error('Token refresh failed:', error);
return null;
}
}
private setupEvents() { private setupEvents() {
this.client.on('ready', () => { this.client.on('ready', () => {
console.log(`Logged in as ${this.client.user?.username || 'Unknown User'}`); console.log(`Logged in as ${this.client.user?.username || 'Unknown User'}`);
@@ -55,21 +114,65 @@ class DiscordService {
} }
public async login() { public async login() {
if (this.loggedIn || this.isLoginInProgress) return;
await this._login();
// load tokens and setup refresh
const tokens = this.loadTokens();
if (tokens) {
const { refreshToken, expiresAt } = tokens;
setTimeout(() => {
if (DiscordService.refreshed) return;
DiscordService.refreshed = true;
console.log('Starting token refresh flow...');
this.refreshTokenFlow(refreshToken).catch(console.error);
}, expiresAt - Date.now() - 60000); // refresh 1 min before expiry
}
}
private async _login() {
if (this.loggedIn) return; if (this.loggedIn) return;
try { try {
console.log('Waiting for authorization (Check Discord popup)...');
this.isLoginInProgress = true; this.isLoginInProgress = true;
await this.client.login({ // Try to load existing token
const savedTokens = this.loadTokens();
if (savedTokens) {
if (this.isTokenValid(savedTokens)) {
console.log('Using saved token...');
await this.authAndLoginClient({
clientId: CONFIG.CLIENT_ID,
accessToken: savedTokens.accessToken,
});
this.loggedIn = true;
console.log('Authenticated with saved token!');
return;
} else {
// Try to refresh
console.log('Token expired, refreshing...');
const refreshed = await this.refreshAccessToken(savedTokens.refreshToken);
if (refreshed) {
await this.authAndLoginClient({
clientId: CONFIG.CLIENT_ID,
accessToken: refreshed.accessToken,
});
this.loggedIn = true;
console.log('Authenticated with refreshed token!');
return;
}
}
}
// Fall back to full OAuth flow
console.log('No valid token found. Starting authorization...');
const client = await this.authAndLoginClient({
clientId: CONFIG.CLIENT_ID, clientId: CONFIG.CLIENT_ID,
clientSecret: CONFIG.CLIENT_SECRET, clientSecret: CONFIG.CLIENT_SECRET,
scopes: ['rpc', 'rpc.voice.read', 'rpc.voice.write'],
redirectUri: CONFIG.REDIRECT_URI,
}); });
this.loggedIn = true; this.loggedIn = true;
console.log('Authenticated successfully with Discord!'); console.log('Authenticated successfully with Discord!');
} catch (error: any) { } catch (error: unknown) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
console.error('Token Exchange Failed:', error.response?.data || error.message); console.error('Token Exchange Failed:', error.response?.data || error.message);
} else { } else {
@@ -87,6 +190,71 @@ class DiscordService {
public getClient() { public getClient() {
return this.client; return this.client;
} }
// dev: refresh only once
static refreshed = false;
private async authAndLoginClient({
clientId,
clientSecret,
accessToken,
}: {
clientId: string;
} & (
| {
clientSecret: string;
accessToken?: string;
}
| {
accessToken: string;
clientSecret?: string;
}
)): Promise<RPC.Client> {
const scopes = ['rpc', 'rpc.voice.read', 'rpc.voice.write'];
if (clientSecret) {
this.client = await this.client.login({
clientId,
clientSecret,
scopes,
redirectUri: CONFIG.REDIRECT_URI,
});
console.log('[Auth] RPC client logged in with client secret');
// store the access token for later use
const accessToken = this.client.accessToken;
const refreshToken = this.client.refreshToken;
const expiresAt = this.client.accessTokenExpiresAt || 0;
if (accessToken && refreshToken && expiresAt) {
this.saveTokens({ accessToken, refreshToken, expiresAt });
} else {
console.warn('Could not retrieve tokens after login with client secret.');
}
return this.client;
}
if (accessToken) {
this.client = await this.client.login({
clientId,
accessToken: accessToken,
scopes,
});
console.log('[Auth] RPC client logged in');
return this.client;
} else {
throw new Error('Either clientSecret or accessToken must be provided');
}
}
private async refreshTokenFlow(refreshToken: string): Promise<void> {
console.log('Refreshing access token...');
const newTokens = await this.refreshAccessToken(refreshToken);
console.log('Token refresh attempt finished.');
if (newTokens) {
// directly update client tokens
this.client.accessToken = newTokens.accessToken;
this.client.refreshToken = newTokens.refreshToken;
this.client.accessTokenExpiresAt = newTokens.expiresAt;
} else {
throw new Error('Failed to refresh token');
}
}
} }
export const discordService = new DiscordService(); export const discordService = new DiscordService();