diff --git a/packages/types/src/discord/oauth2.ts b/packages/types/src/discord/oauth2.ts index 53039c0d2..1dfa644f2 100644 --- a/packages/types/src/discord/oauth2.ts +++ b/packages/types/src/discord/oauth2.ts @@ -141,6 +141,8 @@ export interface DiscordTokenExchangeAuthorizationCode { code: string /** The redirect_uri associated with this authorization */ redirect_uri: string + /** The code verifier for the token exchange if one was sent during the authorization request */ + code_verifier?: string } /** https://discord.com/developers/docs/topics/oauth2#authorization-code-grant-access-token-response */ diff --git a/packages/utils/src/base64.ts b/packages/utils/src/base64.ts index 1aa9bdebd..4c6232632 100644 --- a/packages/utils/src/base64.ts +++ b/packages/utils/src/base64.ts @@ -5,27 +5,42 @@ */ export function encode(data: Uint8Array | ArrayBuffer | string): string { const uint8 = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data) + return _encode(uint8, base64abc, false) +} + +/** + * Encodes a given Uint8Array, ArrayBuffer or string into RFC4648 base64url representation + * @param data The data to encode + * @returns The base64url encoded string + */ +export function encodeBase64Url(data: Uint8Array | ArrayBuffer | string): string { + const uint8 = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data) + return _encode(uint8, base64urlAbc, true) +} + +/** @private */ +function _encode(data: Uint8Array, alpha: string[], skipPadding: boolean): string { let result = '' let i - const l = uint8.length + const l = data.length for (i = 2; i < l; i += 3) { - result += base64abc[uint8[i - 2] >> 2] - result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)] - result += base64abc[((uint8[i - 1] & 0x0f) << 2) | (uint8[i] >> 6)] - result += base64abc[uint8[i] & 0x3f] + result += alpha[data[i - 2] >> 2] + result += alpha[((data[i - 2] & 0x03) << 4) | (data[i - 1] >> 4)] + result += alpha[((data[i - 1] & 0x0f) << 2) | (data[i] >> 6)] + result += alpha[data[i] & 0x3f] } if (i === l + 1) { // 1 octet yet to write - result += base64abc[uint8[i - 2] >> 2] - result += base64abc[(uint8[i - 2] & 0x03) << 4] - result += '==' + result += alpha[data[i - 2] >> 2] + result += alpha[(data[i - 2] & 0x03) << 4] + if (!skipPadding) result += '==' } if (i === l) { // 2 octets yet to write - result += base64abc[uint8[i - 2] >> 2] - result += base64abc[((uint8[i - 2] & 0x03) << 4) | (uint8[i - 1] >> 4)] - result += base64abc[(uint8[i - 1] & 0x0f) << 2] - result += '=' + result += alpha[data[i - 2] >> 2] + result += alpha[((data[i - 2] & 0x03) << 4) | (data[i - 1] >> 4)] + result += alpha[(data[i - 1] & 0x0f) << 2] + if (!skipPadding) result += '=' } return result } @@ -143,6 +158,73 @@ const base64abc = [ '/', ] +const base64urlAbc = [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '-', + '_', +] + // CREDIT: https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 const base64codes = [ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, diff --git a/packages/utils/src/oauth2.ts b/packages/utils/src/oauth2.ts index 03a2e60cd..8bfae2d57 100644 --- a/packages/utils/src/oauth2.ts +++ b/packages/utils/src/oauth2.ts @@ -1,4 +1,5 @@ import type { BigString, DiscordApplicationIntegrationType, OAuth2Scope, PermissionStrings } from '@discordeno/types' +import { encodeBase64Url } from './base64.js' import { calculateBits } from './permissions.js' export function createOAuth2Link(options: CreateOAuth2LinkOptions): string { @@ -15,9 +16,45 @@ export function createOAuth2Link(options: CreateOAuth2LinkOptions): string { if (options.disableGuildSelect !== undefined) url += `&disable_guild_select=${options.disableGuildSelect}` if (options.integrationType) url += `&integration_type=${options.integrationType}` + // Options defined by RFC 7636 (https://datatracker.ietf.org/doc/html/rfc7636) + if (options.codeChallenge) url += `&code_challenge=${options.codeChallenge}` + if (options.codeChallengeMethod) url += `&code_challenge_method=${options.codeChallengeMethod}` + return url } +/** + * Generates a code verifier for use in the PKCE extension to OAuth2. + * + * @param octetLength - The length of the code verifier in octets (default is 32). + * @return The base64url encoded code verifier + * + * @remarks + * The entropy of the code verifier should be between 256 and 768 bits (32 to 96 octets), as the resulting base64url encoded string has to be between 43 and 128 characters long. + * + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 for the octet length + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 for why 32 octets is the default + */ +export function generateCodeVerifier(octetLength: number = 32) { + const randomBytes = new Uint8Array(octetLength) + crypto.getRandomValues(randomBytes) + return encodeBase64Url(randomBytes) +} + +/** + * Creates a code challenge from the code verifier using the specified method. + * + * @param verifier - The code verifier to use. + * @returns The code challenge. + * + * @remarks + * This performs a SHA-256 hash on the verifier and encodes it using base64url encoding. Discord only supports 'S256' as the code challenge method. + */ +export async function createCodeChallenge(verifier: string) { + const hashed = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + return encodeBase64Url(hashed) +} + export interface CreateOAuth2LinkOptions { /** * The type of response @@ -83,4 +120,19 @@ export interface CreateOAuth2LinkOptions { * The application must be configured in the Developer Portal to support the provided `integrationType`. */ integrationType?: DiscordApplicationIntegrationType + /** + * The code challenge used to verify the authorization request + * + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + */ + codeChallenge?: string + /** + * The challenge method used to generate the code challenge + * + * @remarks + * While the RFC allows for the 'plain' value to be set, discord does not allow it + * + * @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + */ + codeChallengeMethod?: 'S256' } diff --git a/packages/utils/tests/base64.spec.ts b/packages/utils/tests/base64.spec.ts index 494ecff13..2a7eb38c7 100644 --- a/packages/utils/tests/base64.spec.ts +++ b/packages/utils/tests/base64.spec.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { expect } from 'chai' import { describe, it } from 'mocha' -import { decode, encode } from '../src/base64.js' +import { decode, encode, encodeBase64Url } from '../src/base64.js' describe('base64.ts', () => { describe('encode', () => { @@ -12,11 +12,67 @@ describe('base64.ts', () => { expect(encode(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173, 162]))).to.be.equal('TWFuINCB8KStog==') expect(encode(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173]))).to.be.equal('TWFuINCB8KSt') expect(encode(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173, 162, 63]))).to.be.equal('TWFuINCB8KStoj8=') + expect(encode(new Uint8Array([199, 239, 242]))).to.be.equal('x+/y') + + // From https://datatracker.ietf.org/doc/html/rfc4648#section-10 + expect(encode(new Uint8Array([]))).to.be.equal('') + expect(encode(new Uint8Array([102]))).to.be.equal('Zg==') + expect(encode(new Uint8Array([102, 111]))).to.be.equal('Zm8=') + expect(encode(new Uint8Array([102, 111, 111]))).to.be.equal('Zm9v') + expect(encode(new Uint8Array([102, 111, 111, 98]))).to.be.equal('Zm9vYg==') + expect(encode(new Uint8Array([102, 111, 111, 98, 97]))).to.be.equal('Zm9vYmE=') + expect(encode(new Uint8Array([102, 111, 111, 98, 97, 114]))).to.be.equal('Zm9vYmFy') }) it('can encode Buffer to base64', () => { expect(encode(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173, 162]))).to.be.equal('TWFuINCB8KStog==') expect(encode(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173]))).to.be.equal('TWFuINCB8KSt') expect(encode(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173, 162, 63]))).to.be.equal('TWFuINCB8KStoj8=') + expect(encode(Buffer.from([199, 239, 242]))).to.be.equal('x+/y') + + // From https://datatracker.ietf.org/doc/html/rfc4648#section-10 + expect(encode(Buffer.from([]))).to.be.equal('') + expect(encode(Buffer.from([102]))).to.be.equal('Zg==') + expect(encode(Buffer.from([102, 111]))).to.be.equal('Zm8=') + expect(encode(Buffer.from([102, 111, 111]))).to.be.equal('Zm9v') + expect(encode(Buffer.from([102, 111, 111, 98]))).to.be.equal('Zm9vYg==') + expect(encode(Buffer.from([102, 111, 111, 98, 97]))).to.be.equal('Zm9vYmE=') + expect(encode(Buffer.from([102, 111, 111, 98, 97, 114]))).to.be.equal('Zm9vYmFy') + }) + }) + + describe('encode base64 url', () => { + it('can encode string to base64 url', () => { + expect(encodeBase64Url('Man Ё𤭢')).to.be.equal('TWFuINCB8KStog') + }) + it('can encode Uint8Array to base64 url', () => { + expect(encodeBase64Url(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173, 162]))).to.be.equal('TWFuINCB8KStog') + expect(encodeBase64Url(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173]))).to.be.equal('TWFuINCB8KSt') + expect(encodeBase64Url(new Uint8Array([77, 97, 110, 32, 208, 129, 240, 164, 173, 162, 63]))).to.be.equal('TWFuINCB8KStoj8') + expect(encodeBase64Url(new Uint8Array([199, 239, 242]))).to.be.equal('x-_y') + + // From https://datatracker.ietf.org/doc/html/rfc4648#section-10 + expect(encodeBase64Url(new Uint8Array([]))).to.be.equal('') + expect(encodeBase64Url(new Uint8Array([102]))).to.be.equal('Zg') + expect(encodeBase64Url(new Uint8Array([102, 111]))).to.be.equal('Zm8') + expect(encodeBase64Url(new Uint8Array([102, 111, 111]))).to.be.equal('Zm9v') + expect(encodeBase64Url(new Uint8Array([102, 111, 111, 98]))).to.be.equal('Zm9vYg') + expect(encodeBase64Url(new Uint8Array([102, 111, 111, 98, 97]))).to.be.equal('Zm9vYmE') + expect(encodeBase64Url(new Uint8Array([102, 111, 111, 98, 97, 114]))).to.be.equal('Zm9vYmFy') + }) + it('can encode Buffer to base64 url', () => { + expect(encodeBase64Url(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173, 162]))).to.be.equal('TWFuINCB8KStog') + expect(encodeBase64Url(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173]))).to.be.equal('TWFuINCB8KSt') + expect(encodeBase64Url(Buffer.from([77, 97, 110, 32, 208, 129, 240, 164, 173, 162, 63]))).to.be.equal('TWFuINCB8KStoj8') + expect(encodeBase64Url(Buffer.from([199, 239, 242]))).to.be.equal('x-_y') + + // From https://datatracker.ietf.org/doc/html/rfc4648#section-10 + expect(encodeBase64Url(Buffer.from([]))).to.be.equal('') + expect(encodeBase64Url(Buffer.from([102]))).to.be.equal('Zg') + expect(encodeBase64Url(Buffer.from([102, 111]))).to.be.equal('Zm8') + expect(encodeBase64Url(Buffer.from([102, 111, 111]))).to.be.equal('Zm9v') + expect(encodeBase64Url(Buffer.from([102, 111, 111, 98]))).to.be.equal('Zm9vYg') + expect(encodeBase64Url(Buffer.from([102, 111, 111, 98, 97]))).to.be.equal('Zm9vYmE') + expect(encodeBase64Url(Buffer.from([102, 111, 111, 98, 97, 114]))).to.be.equal('Zm9vYmFy') }) })