Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/Discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ import {ConsoleLevel, consoleLevels, wrapConsoleMethod} from './utils/console';
import type {TSendCommand, TSendCommandPayload} from './schema/types';
import {IDiscordSDK, MaybeZodObjectArray, SdkConfiguration} from './interface';
import {version as sdkVersion} from '../package.json';
import {ProxyTokenMonitor} from './utils/ProxyTokenMonitor';

interface TokenRefreshState {
monitor: ProxyTokenMonitor;
promise: Promise<boolean> | null;
lastRefreshTime: number;
}

const TOKEN_REFRESH_RATE_LIMIT_MS = 60000; // 1 minute

export enum Opcodes {
HANDSHAKE = 0,
Expand Down Expand Up @@ -85,6 +94,7 @@ export class DiscordSDK implements IDiscordSDK {
reject: (error: unknown) => unknown;
}
> = new Map();
private tokenRefresh: TokenRefreshState;

private getTransfer(payload: TSendCommandPayload): Transferable[] | undefined {
switch (payload.cmd) {
Expand Down Expand Up @@ -114,6 +124,12 @@ export class DiscordSDK implements IDiscordSDK {
this.clientId = clientId;
this.configuration = configuration ?? getDefaultSdkConfiguration();

this.tokenRefresh = {
monitor: new ProxyTokenMonitor(),
promise: null,
lastRefreshTime: 0,
};

if (typeof window !== 'undefined') {
window.addEventListener('message', this.handleMessage);
}
Expand Down Expand Up @@ -167,9 +183,14 @@ export class DiscordSDK implements IDiscordSDK {
[this.source, this.sourceOrigin] = getRPCServerSource();
this.addOnReadyListener();
this.handshake();

if (this.configuration.autoRefreshProxyToken) {
this.tokenRefresh.monitor.enable(() => this.refreshProxyToken());
}
}
close(code: RPCCloseCodes, message: string) {
window.removeEventListener('message', this.handleMessage);
this.tokenRefresh.monitor.disable();

const nonce = uuidv4();
this.source?.postMessage([Opcodes.CLOSE, {code, message, nonce}], this.sourceOrigin);
Expand Down Expand Up @@ -342,6 +363,51 @@ export class DiscordSDK implements IDiscordSDK {
this.pendingCommands.delete(parsed.nonce);
}
}

async refreshProxyToken(): Promise<boolean> {
if (this.tokenRefresh.promise) {
return this.tokenRefresh.promise;
}

const now = Date.now();
if (now - this.tokenRefresh.lastRefreshTime < TOKEN_REFRESH_RATE_LIMIT_MS) {
return true;
}

this.tokenRefresh.promise = (async () => {
if (typeof window === 'undefined') {
return false;
}

try {
const response = await this.commands.requestProxyTicketRefresh();

if (!response || !response.ticket) {
return false;
}

const exchangeResponse = await fetch(`${window.location.origin}/?discord_proxy_ticket=${response.ticket}`, {
method: 'HEAD',
credentials: 'include',
});

return exchangeResponse.ok;
} catch {
return false;
}
})();

try {
const result = await this.tokenRefresh.promise;
if (result) {
this.tokenRefresh.lastRefreshTime = now;
}
return result;
} finally {
this.tokenRefresh.promise = null;
}
}

_getSearch() {
return typeof window === 'undefined' ? '' : window.location.search;
}
Expand Down
133 changes: 132 additions & 1 deletion src/__tests__/discordSdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import {Opcodes} from '../Discord';
import {DiscordSDK, Events, Platform} from '../index';
import {DiscordSDK, Events, Platform, RPCCloseCodes} from '../index';
import {DISPATCH} from '../schema/common';
import {version as sdkVersion} from '../../package.json';

Expand Down Expand Up @@ -88,4 +88,135 @@ describe('DiscordSDK', () => {
// Verify "READY" event resolves
await discordSdk.ready();
});

describe('Proxy Token Refresh', () => {
let discordSdk: DiscordSDK;
let mockPostMessage: jest.Mock;

beforeEach(() => {
const frame_id = '1234';
const instance_id = '2345';
const platform = Platform.DESKTOP;
const clientId = '1234567890';

Object.defineProperty(window, 'location', {
value: {
get pathname() {
return jest.fn();
},
replace: jest.fn(),
get search() {
return `?${new URLSearchParams({
frame_id,
instance_id,
platform,
}).toString()}`;
},
get origin() {
return 'https://example.com';
},
},
});

mockPostMessage = jest.fn();
Object.defineProperty(window, 'parent', {
value: {
postMessage: mockPostMessage,
},
});

Object.defineProperty(document, 'cookie', {
writable: true,
value: '',
});

global.fetch = jest.fn();
jest.clearAllMocks();

discordSdk = new DiscordSDK(clientId);
});

afterEach(() => {
discordSdk.close(RPCCloseCodes.CLOSE_NORMAL, 'Test cleanup');
});

it('should have autoRefreshProxyToken disabled by default', () => {
expect(discordSdk.configuration.autoRefreshProxyToken).toBe(false);
});

it('should enable autoRefreshProxyToken through constructor config', () => {
const clientId = '1234567890';
const sdkWithAutoRefresh = new DiscordSDK(clientId, {
disableConsoleLogOverride: false,
autoRefreshProxyToken: true,
});

expect(sdkWithAutoRefresh.configuration.autoRefreshProxyToken).toBe(true);

sdkWithAutoRefresh.close(RPCCloseCodes.CLOSE_NORMAL, 'Test cleanup');
});

it('should have refreshProxyToken method available', () => {
expect(typeof discordSdk.refreshProxyToken).toBe('function');
});

it('rate limits subsequent refresh calls within the window and returns true (token is fresh)', async () => {
jest.useFakeTimers({now: 1_000_000});

const originalCommand = discordSdk.commands.requestProxyTicketRefresh;
discordSdk.commands.requestProxyTicketRefresh = jest.fn().mockResolvedValue({ticket: 'test'});

const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({ok: true} as Response);

const result1 = await discordSdk.refreshProxyToken();
expect(result1).toBe(true);
expect(discordSdk.commands.requestProxyTicketRefresh).toHaveBeenCalledTimes(1);

(discordSdk.commands.requestProxyTicketRefresh as jest.Mock).mockClear();

// Within the 1-minute rate-limit window, no RPC is sent and we still report the token as fresh
jest.advanceTimersByTime(30_000);
const result2 = await discordSdk.refreshProxyToken();
expect(result2).toBe(true);
expect(discordSdk.commands.requestProxyTicketRefresh).toHaveBeenCalledTimes(0);

// After the window passes, refresh proceeds again
jest.advanceTimersByTime(31_000);
const result3 = await discordSdk.refreshProxyToken();
expect(result3).toBe(true);
expect(discordSdk.commands.requestProxyTicketRefresh).toHaveBeenCalledTimes(1);

discordSdk.commands.requestProxyTicketRefresh = originalCommand;
jest.useRealTimers();
});

it('coalesces concurrent refresh calls into a single in-flight request', async () => {
const originalCommand = discordSdk.commands.requestProxyTicketRefresh;

let resolveCommand: (value: {ticket: string}) => void = () => {};
const commandPromise = new Promise<{ticket: string}>((resolve) => {
resolveCommand = resolve;
});
discordSdk.commands.requestProxyTicketRefresh = jest.fn().mockReturnValue(commandPromise);

const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue({ok: true} as Response);

const p1 = discordSdk.refreshProxyToken();
const p2 = discordSdk.refreshProxyToken();
const p3 = discordSdk.refreshProxyToken();

resolveCommand({ticket: 'test'});
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);

expect(r1).toBe(true);
expect(r2).toBe(true);
expect(r3).toBe(true);
expect(discordSdk.commands.requestProxyTicketRefresh).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(1);

discordSdk.commands.requestProxyTicketRefresh = originalCommand;
});
});
});
7 changes: 7 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export interface SdkConfiguration {
* Setting this flag to true will disable this functionality
*/
readonly disableConsoleLogOverride: boolean;
/**
* Enables automatic proxy token refresh to maintain long-running embedded activities.
* When enabled, the SDK will monitor proxy token expiration and automatically refresh
* tokens before they expire. Defaults to false for backwards compatibility.
*/
readonly autoRefreshProxyToken?: boolean;
}

export type MaybeZodObjectArray<T extends EventArgs> =
Expand Down Expand Up @@ -45,4 +51,5 @@ export interface IDiscordSDK {
...unsubscribeArgs: MaybeZodObjectArray<(typeof EventSchema)[K]>
): Promise<unknown>;
ready(): Promise<void>;
refreshProxyToken(): Promise<boolean>;
}
5 changes: 5 additions & 0 deletions src/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export class DiscordSDKMock implements IDiscordSDK {
emitEvent<T>(event: string, data: T) {
this.eventBus.emit(event, data);
}

refreshProxyToken(): Promise<boolean> {
console.info('DiscordSDKMock: refreshProxyToken()');
return Promise.resolve(true);
}
}
/** Default return values for all discord SDK commands */
export const commandsMockDefault: IDiscordSDK['commands'] = {
Expand Down
97 changes: 97 additions & 0 deletions src/utils/ProxyTokenMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
interface TokenData {
application_id: string;
user_id: string;
is_developer: boolean;
created_at: number;
expires_at: number;
}

const REFRESH_THRESHOLD_SECONDS = 15 * 60;
const RETRY_DELAY_MS = 5 * 60 * 1000;

export class ProxyTokenMonitor {
private timeoutId: number | null = null;
private enabled: boolean = false;
private onRefreshNeeded?: () => Promise<unknown>;

private parseTokenCookie(): TokenData | null {
if (typeof document === 'undefined') return null;

let token: string | undefined;
for (const rawCookie of document.cookie.split(';')) {
const cookie = rawCookie.trim();
const eq = cookie.indexOf('=');
if (eq === -1) continue;
if (cookie.slice(0, eq) === 'discord_proxy_token') {
token = cookie.slice(eq + 1);
break;
}
}
if (!token) return null;

try {
const [payloadB64] = token.split('.');
if (!payloadB64) return null;

const payloadJson =
typeof Buffer !== 'undefined' ? Buffer.from(payloadB64, 'base64').toString('utf-8') : atob(payloadB64);

return JSON.parse(payloadJson) as TokenData;
} catch {
return null;
}
}

private calculateTimeUntilRefresh(tokenData: TokenData): number {
const now = Math.floor(Date.now() / 1000);
const refreshTime = tokenData.expires_at - REFRESH_THRESHOLD_SECONDS;
return Math.max(0, (refreshTime - now) * 1000);
}

private scheduleRefresh(): void {
const tokenData = this.parseTokenCookie();
if (!tokenData || !this.enabled) return;

const oldExpiresAt = tokenData.expires_at;
const msUntilRefresh = this.calculateTimeUntilRefresh(tokenData);

this.timeoutId = window.setTimeout(async () => {
if (!this.enabled) return;

try {
await this.onRefreshNeeded?.();
} catch {
// fall through to expiry check; a thrown callback is treated as a failed refresh
}
if (!this.enabled) return;

const newTokenData = this.parseTokenCookie();
if (newTokenData && newTokenData.expires_at > oldExpiresAt) {
this.scheduleRefresh();
} else {
this.timeoutId = window.setTimeout(() => this.scheduleRefresh(), RETRY_DELAY_MS);
}
}, msUntilRefresh);
}

public enable(onRefreshNeeded: () => Promise<unknown>): void {
if (this.enabled || typeof window === 'undefined') return;

this.onRefreshNeeded = onRefreshNeeded;
this.enabled = true;

this.scheduleRefresh();
}

public disable(): void {
if (!this.enabled) return;

this.enabled = false;
if (this.timeoutId != null && typeof window !== 'undefined') {
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}

export type {TokenData};
Loading