feat(rest)!: allow passing tokens per request (#10682)

BREAKING CHANGE: `RequestData.authPrefix` has been removed in favor of `RequestData.auth.prefix`
This commit is contained in:
ckohen
2025-01-12 21:36:05 -08:00
committed by GitHub
parent 11438c230b
commit ae0265eefc
8 changed files with 71 additions and 22 deletions

View File

@@ -184,7 +184,7 @@ test('getAuth', async () => {
(from) => ({ auth: (from.headers as unknown as Record<string, string | undefined>).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 () => {

View File

@@ -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:^",

View File

@@ -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<RestEvents> {
public async queueRequest(request: InternalRequest): Promise<ResponseLike> {
// 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<RestEvents> {
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<RestEvents> {
// 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

View File

@@ -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) {

View File

@@ -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!);
}

View File

@@ -60,3 +60,5 @@ export const OverwrittenMimeTypes = {
} as const satisfies Readonly<Record<string, string>>;
export const BurstHandlerMajorIdKey = 'burst';
export const AUTH_UUID_NAMESPACE = 'acc82a4c-f887-417b-a69c-f74096ff7e59';

View File

@@ -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<InternalRequest, 'auth' | 'body' | 'files' | 'signal'>;
export interface HandlerRequestData extends Pick<InternalRequest, 'body' | 'files' | 'signal'> {
auth: boolean | string;
}
/**
* Parsed route data for an endpoint

9
pnpm-lock.yaml generated
View File

@@ -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: {}