diff --git a/src/Discord.ts b/src/Discord.ts index cbf26cbc..d12146a1 100644 --- a/src/Discord.ts +++ b/src/Discord.ts @@ -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 | null; + lastRefreshTime: number; +} + +const TOKEN_REFRESH_RATE_LIMIT_MS = 60000; // 1 minute export enum Opcodes { HANDSHAKE = 0, @@ -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) { @@ -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); } @@ -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); @@ -342,6 +363,51 @@ export class DiscordSDK implements IDiscordSDK { this.pendingCommands.delete(parsed.nonce); } } + + async refreshProxyToken(): Promise { + 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; } diff --git a/src/__tests__/discordSdk.test.ts b/src/__tests__/discordSdk.test.ts index 8706ec25..33aef9ad 100644 --- a/src/__tests__/discordSdk.test.ts +++ b/src/__tests__/discordSdk.test.ts @@ -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'; @@ -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; + 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; + 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; + }); + }); }); diff --git a/src/interface.ts b/src/interface.ts index 8e15093e..254bd6e3 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -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 = @@ -45,4 +51,5 @@ export interface IDiscordSDK { ...unsubscribeArgs: MaybeZodObjectArray<(typeof EventSchema)[K]> ): Promise; ready(): Promise; + refreshProxyToken(): Promise; } diff --git a/src/mock.ts b/src/mock.ts index 224f3e95..7801c5ce 100644 --- a/src/mock.ts +++ b/src/mock.ts @@ -82,6 +82,11 @@ export class DiscordSDKMock implements IDiscordSDK { emitEvent(event: string, data: T) { this.eventBus.emit(event, data); } + + refreshProxyToken(): Promise { + console.info('DiscordSDKMock: refreshProxyToken()'); + return Promise.resolve(true); + } } /** Default return values for all discord SDK commands */ export const commandsMockDefault: IDiscordSDK['commands'] = { diff --git a/src/utils/ProxyTokenMonitor.ts b/src/utils/ProxyTokenMonitor.ts new file mode 100644 index 00000000..e3f02979 --- /dev/null +++ b/src/utils/ProxyTokenMonitor.ts @@ -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; + + 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): 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}; diff --git a/src/utils/__tests__/ProxyTokenMonitor.test.ts b/src/utils/__tests__/ProxyTokenMonitor.test.ts new file mode 100644 index 00000000..f5518dd0 --- /dev/null +++ b/src/utils/__tests__/ProxyTokenMonitor.test.ts @@ -0,0 +1,245 @@ +/** + * @jest-environment jsdom + */ +import {ProxyTokenMonitor, TokenData} from '../ProxyTokenMonitor'; + +describe('ProxyTokenMonitor', () => { + let monitor: ProxyTokenMonitor; + let mockOnRefreshNeeded: jest.Mock; + + beforeEach(() => { + monitor = new ProxyTokenMonitor(); + mockOnRefreshNeeded = jest.fn().mockResolvedValue(undefined); + jest.clearAllTimers(); + jest.useFakeTimers(); + + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + }); + }); + + afterEach(() => { + monitor.disable(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + // Token format produced by the proxy: base64(JSON payload).base64(signature) + // See discord_static/worker/routes/application-router-domain/proxyAuth.tsx + const createValidToken = (expiresIn: number = 3600, overrides: Partial = {}): string => { + const payload: TokenData = { + application_id: '12345', + user_id: '67890', + is_developer: false, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + expiresIn, + ...overrides, + }; + + const payloadB64 = btoa(JSON.stringify(payload)); + const signature = btoa('mock-signature'); + + return `${payloadB64}.${signature}`; + }; + + describe('token parsing', () => { + it('parses a token from cookies', () => { + const token = createValidToken(3600); + document.cookie = `discord_proxy_token=${token}`; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + const tokenData = parseTokenCookie(); + + expect(tokenData).toMatchObject({ + application_id: '12345', + user_id: '67890', + is_developer: false, + }); + expect(tokenData.expires_at).toBeGreaterThan(Math.floor(Date.now() / 1000)); + }); + + it('parses a token whose value contains base64 = padding', () => { + // Construct a payload sized so its base64 has = padding + const payload = { + application_id: '12345', + user_id: '67890', + is_developer: false, + created_at: 1000, + expires_at: 9999, + }; + const payloadB64 = btoa(JSON.stringify(payload)); + // Sanity: standard base64 of this JSON ends with '=' padding + expect(payloadB64.endsWith('=')).toBe(true); + + const token = `${payloadB64}.${btoa('sig')}`; + document.cookie = `discord_proxy_token=${token}`; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + const tokenData = parseTokenCookie(); + + expect(tokenData).toMatchObject(payload); + }); + + it('returns null when no token cookie exists', () => { + document.cookie = 'other_cookie=value'; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + expect(parseTokenCookie()).toBeNull(); + }); + + it('returns null for malformed token', () => { + document.cookie = 'discord_proxy_token=invalid-token'; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + expect(parseTokenCookie()).toBeNull(); + }); + + it('returns null when payload segment is empty', () => { + document.cookie = 'discord_proxy_token=.signature'; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + expect(parseTokenCookie()).toBeNull(); + }); + + it('parses the correct cookie when other cookies are present', () => { + const token = createValidToken(3600); + document.cookie = `other=foo; discord_proxy_token=${token}; another=bar`; + + const parseTokenCookie = (monitor as any).parseTokenCookie.bind(monitor); + expect(parseTokenCookie()).toMatchObject({application_id: '12345'}); + }); + }); + + describe('time calculation', () => { + it('returns 0 for tokens already inside the refresh window', () => { + const tokenData: TokenData = { + application_id: '12345', + user_id: '67890', + is_developer: false, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + 600, + }; + + const calculateTimeUntilRefresh = (monitor as any).calculateTimeUntilRefresh.bind(monitor); + expect(calculateTimeUntilRefresh(tokenData)).toBe(0); + }); + + it('schedules 15 minutes before expiry', () => { + const tokenData: TokenData = { + application_id: '12345', + user_id: '67890', + is_developer: false, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + + const calculateTimeUntilRefresh = (monitor as any).calculateTimeUntilRefresh.bind(monitor); + expect(calculateTimeUntilRefresh(tokenData)).toBe(45 * 60 * 1000); + }); + }); + + describe('monitoring lifecycle', () => { + it('enables monitoring and schedules a timeout', () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + + monitor.enable(mockOnRefreshNeeded); + + expect(setTimeoutSpy).toHaveBeenCalled(); + }); + + it('clears the scheduled timeout on disable', () => { + const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + + monitor.enable(mockOnRefreshNeeded); + monitor.disable(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('does not enable monitoring twice', () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + + monitor.enable(mockOnRefreshNeeded); + monitor.enable(mockOnRefreshNeeded); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + }); + + it('triggers refresh immediately when token is already inside the threshold', () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(600)}`; + + monitor.enable(mockOnRefreshNeeded); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); + }); + + it('schedules refresh 15 minutes before future expiry', () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + + monitor.enable(mockOnRefreshNeeded); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 45 * 60 * 1000); + }); + + it('calls onRefreshNeeded when the scheduled timeout fires', async () => { + document.cookie = `discord_proxy_token=${createValidToken(600)}`; + + monitor.enable(mockOnRefreshNeeded); + await jest.runOnlyPendingTimersAsync(); + + expect(mockOnRefreshNeeded).toHaveBeenCalled(); + }); + + it('reschedules from the new token when expires_at advances after the callback', async () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(600)}`; + + mockOnRefreshNeeded.mockImplementation(() => { + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + return Promise.resolve(); + }); + + monitor.enable(mockOnRefreshNeeded); + setTimeoutSpy.mockClear(); + await jest.runOnlyPendingTimersAsync(); + + // After successful refresh, the next setTimeout uses the new token's expiry: 45 minutes + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 45 * 60 * 1000); + }); + + it('retries in 5 minutes when the cookie did not advance', async () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(600)}`; + + // Callback does nothing -> cookie unchanged + monitor.enable(mockOnRefreshNeeded); + setTimeoutSpy.mockClear(); + await jest.runOnlyPendingTimersAsync(); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5 * 60 * 1000); + }); + + it('does not reschedule after disable() during an in-flight callback', async () => { + const setTimeoutSpy = jest.spyOn(window, 'setTimeout'); + document.cookie = `discord_proxy_token=${createValidToken(600)}`; + + mockOnRefreshNeeded.mockImplementation(() => { + monitor.disable(); + document.cookie = `discord_proxy_token=${createValidToken(3600)}`; + return Promise.resolve(); + }); + + monitor.enable(mockOnRefreshNeeded); + setTimeoutSpy.mockClear(); + await jest.runOnlyPendingTimersAsync(); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/getDefaultSdkConfiguration.ts b/src/utils/getDefaultSdkConfiguration.ts index 66b26842..0607f4d4 100644 --- a/src/utils/getDefaultSdkConfiguration.ts +++ b/src/utils/getDefaultSdkConfiguration.ts @@ -3,5 +3,6 @@ import {SdkConfiguration} from '../interface'; export default function getDefaultSdkConfiguration(): SdkConfiguration { return { disableConsoleLogOverride: false, + autoRefreshProxyToken: false, }; }