diff --git a/src/lib/__tests__/pkce.test.js b/src/lib/__tests__/pkce.test.js new file mode 100644 index 0000000..298cead --- /dev/null +++ b/src/lib/__tests__/pkce.test.js @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'vitest' +import { + toSha256, + toBase64Url, + createRandomString, + createPKCECodeVerifier, +} from '../pkce' + +describe('createRandomString', () => { + test('throws error on 0 length', () => { + expect(() => createRandomString(0)) + .toThrow('length must be greater than 0') + }) + + test('creates string with length', () => { + const randomString43 = createRandomString(43) + const randomString128 = createRandomString(128) + + expect(randomString43).toHaveLength(43) + expect(randomString128).toHaveLength(128) + }) + + test('creates unique strings', () => { + const randomString1 = createRandomString(43) + const randomString2 = createRandomString(43) + + expect(randomString1).not.toEqual(randomString2) + console.log(randomString1) + }) +}) + +describe('toSha256', () => { + test('throws error on empty string', async () => { + await expect(toSha256('')).rejects.toThrow('data is required') + }) + + test('hashes correctly', async () => { + const hash = await toSha256('hello') + const base64url = toBase64Url(hash) + + // LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ= + // but without + and = + expect(base64url).toEqual('LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ') + }) +}) + +describe('toBase64Url', () => { + test('converts to base64url', () => { + const base64url = toBase64Url([1, 2, 3, 4, 5]) + + expect(base64url).toEqual('AQIDBAU') + }) +}) + +describe('createPKCECodeVerifier', () => { + test('creates code challenge', async () => { + const codeVerifier = createRandomString(43) + const codeChallenge = await createPKCECodeVerifier(codeVerifier) + + expect(codeChallenge).toHaveLength(43) + }) + + test('creates unique code challenge', async () => { + const codeVerifier = createRandomString(43) + const codeChallenge1 = await createPKCECodeVerifier(codeVerifier) + const codeChallenge2 = await createPKCECodeVerifier(codeVerifier) + + expect(codeChallenge1).toEqual(codeChallenge2) + }) +}) diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 0000000..4891367 --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,43 @@ +import { Sha256 } from '@aws-crypto/sha256-js'; + +export const toSha256 = async (data: string): Promise => { + if (!data) throw new Error('data is required'); + + const hash = new Sha256(); + hash.update(data); + + const hashed = await hash.digest(); + return hashed; +} + +// refer to base64url-encoding in RFC 7636 +// https://datatracker.ietf.org/doc/html/rfc7636#appendix-A +export const toBase64Url = (bytes: Uint8Array) => { + const charCodes = Array.from(bytes); + let str = btoa(String.fromCharCode.apply(null, charCodes)); + str = str.split('=')[0]; + str = str.replace(/\+/g, '-'); + str = str.replace(/\//g, '_'); + return str; +} + +// refer to random string generation in RFC 7636 +// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 +export const createRandomString = (length: number = 34): string => { + if (length === 0) throw new Error('length must be greater than 0'); + + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let randomString = ''; + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * charset.length); + randomString += charset[randomIndex]; + } + return randomString; +} + +export const createPKCECodeVerifier = async (str: string): string => { + const hashed: Uint8Array = await toSha256(str) + const codeVerifier = toBase64Url(hashed) + return codeVerifier; +} +