diff --git a/packages/rest/__tests__/REST.test.ts b/packages/rest/__tests__/REST.test.ts index 521d3cd21..2f451699d 100644 --- a/packages/rest/__tests__/REST.test.ts +++ b/packages/rest/__tests__/REST.test.ts @@ -184,7 +184,7 @@ test('getAuth', async () => { (from) => ({ auth: (from.headers as unknown as Record).Authorization ?? null }), responseOptions, ) - .times(3); + .times(5); // default expect(await api.get('/getAuth')).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' }); @@ -202,6 +202,20 @@ test('getAuth', async () => { auth: true, }), ).toStrictEqual({ auth: 'Bot A-Very-Fake-Token' }); + + // Custom Bot Auth + expect( + await api.get('/getAuth', { + auth: { token: 'A-Very-Different-Fake-Token' }, + }), + ).toStrictEqual({ auth: 'Bot A-Very-Different-Fake-Token' }); + + // Custom Bearer Auth + expect( + await api.get('/getAuth', { + auth: { token: 'A-Bearer-Fake-Token', prefix: 'Bearer' }, + }), + ).toStrictEqual({ auth: 'Bearer A-Bearer-Fake-Token' }); }); test('getReason', async () => { diff --git a/packages/rest/package.json b/packages/rest/package.json index 9742db823..472ea35cf 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -91,7 +91,8 @@ "discord-api-types": "^0.37.114", "magic-bytes.js": "^1.10.0", "tslib": "^2.8.1", - "undici": "6.21.0" + "undici": "6.21.0", + "uuid": "^11.0.3" }, "devDependencies": { "@discordjs/api-extractor": "workspace:^", diff --git a/packages/rest/src/lib/REST.ts b/packages/rest/src/lib/REST.ts index e9d9b0f5d..99ddd6994 100644 --- a/packages/rest/src/lib/REST.ts +++ b/packages/rest/src/lib/REST.ts @@ -3,11 +3,13 @@ import { DiscordSnowflake } from '@sapphire/snowflake'; import { AsyncEventEmitter } from '@vladfrangu/async_event_emitter'; import { filetypeinfo } from 'magic-bytes.js'; import type { RequestInit, BodyInit, Dispatcher } from 'undici'; +import { v5 as uuidV5 } from 'uuid'; import { CDN } from './CDN.js'; import { BurstHandler } from './handlers/BurstHandler.js'; import { SequentialHandler } from './handlers/SequentialHandler.js'; import type { IHandler } from './interfaces/Handler.js'; import { + AUTH_UUID_NAMESPACE, BurstHandlerMajorIdKey, DefaultRestOptions, DefaultUserAgent, @@ -25,6 +27,7 @@ import type { RequestHeaders, RouteData, RequestData, + AuthData, } from './utils/types.js'; import { isBufferLike, parseResponse } from './utils/utils.js'; @@ -240,9 +243,11 @@ export class REST extends AsyncEventEmitter { public async queueRequest(request: InternalRequest): Promise { // Generalize the endpoint to its route data const routeId = REST.generateRouteData(request.fullRoute, request.method); + const customAuth = typeof request.auth === 'object' && request.auth.token !== this.#token; + const auth = customAuth ? uuidV5((request.auth as AuthData).token, AUTH_UUID_NAMESPACE) : request.auth !== false; // Get the bucket hash for the generic route, or point to a global route otherwise - const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}`) ?? { - value: `Global(${request.method}:${routeId.bucketRoute})`, + const hash = this.hashes.get(`${request.method}:${routeId.bucketRoute}${customAuth ? `:${auth}` : ''}`) ?? { + value: `Global(${request.method}:${routeId.bucketRoute}${customAuth ? `:${auth}` : ''})`, lastAccess: -1, }; @@ -258,7 +263,7 @@ export class REST extends AsyncEventEmitter { return handler.queueRequest(routeId, url, fetchOptions, { body: request.body, files: request.files, - auth: request.auth !== false, + auth, signal: request.signal, }); } @@ -308,12 +313,16 @@ export class REST extends AsyncEventEmitter { // If this request requires authorization (allowing non-"authorized" requests for webhooks) if (request.auth !== false) { - // If we haven't received a token, throw an error - if (!this.#token) { - throw new Error('Expected token to be set for this request, but none was present'); - } + if (typeof request.auth === 'object') { + headers.Authorization = `${request.auth.prefix ?? this.options.authPrefix} ${request.auth.token}`; + } else { + // If we haven't received a token, throw an error + if (!this.#token) { + throw new Error('Expected token to be set for this request, but none was present'); + } - headers.Authorization = `${request.authPrefix ?? this.options.authPrefix} ${this.#token}`; + headers.Authorization = `${this.options.authPrefix} ${this.#token}`; + } } // If a reason was set, set its appropriate header diff --git a/packages/rest/src/lib/handlers/SequentialHandler.ts b/packages/rest/src/lib/handlers/SequentialHandler.ts index 1779ae68b..1c5ad5ff9 100644 --- a/packages/rest/src/lib/handlers/SequentialHandler.ts +++ b/packages/rest/src/lib/handlers/SequentialHandler.ts @@ -304,11 +304,16 @@ export class SequentialHandler implements IHandler { // Let library users know when rate limit buckets have been updated this.debug(['Received bucket hash update', ` Old Hash : ${this.hash}`, ` New Hash : ${hash}`].join('\n')); // This queue will eventually be eliminated via attrition - this.manager.hashes.set(`${method}:${routeId.bucketRoute}`, { value: hash, lastAccess: Date.now() }); + this.manager.hashes.set( + `${method}:${routeId.bucketRoute}${typeof requestData.auth === 'string' ? `:${requestData.auth}` : ''}`, + { value: hash, lastAccess: Date.now() }, + ); } else if (hash) { // Handle the case where hash value doesn't change // Fetch the hash data from the manager - const hashData = this.manager.hashes.get(`${method}:${routeId.bucketRoute}`); + const hashData = this.manager.hashes.get( + `${method}:${routeId.bucketRoute}${typeof requestData.auth === 'string' ? `:${requestData.auth}` : ''}`, + ); // When fetched, update the last access of the hash if (hashData) { diff --git a/packages/rest/src/lib/handlers/Shared.ts b/packages/rest/src/lib/handlers/Shared.ts index 53d4c72af..7944f73db 100644 --- a/packages/rest/src/lib/handlers/Shared.ts +++ b/packages/rest/src/lib/handlers/Shared.ts @@ -138,7 +138,7 @@ export async function handleErrors( // Handle possible malformed requests if (status >= 400 && status < 500) { // If we receive this status code, it means the token we had is no longer valid. - if (status === 401 && requestData.auth) { + if (status === 401 && requestData.auth === true) { manager.setToken(null!); } diff --git a/packages/rest/src/lib/utils/constants.ts b/packages/rest/src/lib/utils/constants.ts index 73a12255e..f13e334b7 100644 --- a/packages/rest/src/lib/utils/constants.ts +++ b/packages/rest/src/lib/utils/constants.ts @@ -60,3 +60,5 @@ export const OverwrittenMimeTypes = { } as const satisfies Readonly>; export const BurstHandlerMajorIdKey = 'burst'; + +export const AUTH_UUID_NAMESPACE = 'acc82a4c-f887-417b-a69c-f74096ff7e59'; diff --git a/packages/rest/src/lib/utils/types.ts b/packages/rest/src/lib/utils/types.ts index e45d6614f..052c05330 100644 --- a/packages/rest/src/lib/utils/types.ts +++ b/packages/rest/src/lib/utils/types.ts @@ -269,6 +269,19 @@ export interface RawFile { name: string; } +export interface AuthData { + /** + * The authorization prefix to use for this request, useful if you use this with bearer tokens + * + * @defaultValue `REST.options.authPrefix` + */ + prefix?: 'Bearer' | 'Bot'; + /** + * The authorization token to use for this request + */ + token: string; +} + /** * Represents possible data to be given to an endpoint */ @@ -278,17 +291,11 @@ export interface RequestData { */ appendToFormData?: boolean; /** - * If this request needs the `Authorization` header + * Alternate authorization data to use for this request only, or `false` to disable the Authorization header * * @defaultValue `true` */ - auth?: boolean; - /** - * The authorization prefix to use for this request, useful if you use this with bearer tokens - * - * @defaultValue `'Bot'` - */ - authPrefix?: 'Bearer' | 'Bot'; + auth?: AuthData | boolean; /** * The body to send to this request. * If providing as BodyInit, set `passThroughBody: true` @@ -363,7 +370,9 @@ export interface InternalRequest extends RequestData { method: RequestMethod; } -export type HandlerRequestData = Pick; +export interface HandlerRequestData extends Pick { + auth: boolean | string; +} /** * Parsed route data for an endpoint diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3e164166..24f9433c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,9 @@ importers: undici: specifier: 6.21.0 version: 6.21.0 + uuid: + specifier: ^11.0.3 + version: 11.0.3 devDependencies: '@discordjs/api-extractor': specifier: workspace:^ @@ -12849,6 +12852,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + uuid@3.3.2: resolution: {integrity: sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. @@ -27849,6 +27856,8 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.18 + uuid@11.0.3: {} + uuid@3.3.2: {} uuid@3.4.0: {}