diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts new file mode 100644 index 0000000..4cac0e7 --- /dev/null +++ b/src/lib/base64url.ts @@ -0,0 +1,8 @@ +export function base64UrlEncode(bytes: Uint8Array): string { + const binaryString = String.fromCharCode(...bytes) + + return btoa(binaryString) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} \ No newline at end of file diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 0000000..87d31f9 --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,35 @@ +import { base64UrlEncode } from "./base64url"; + +export interface PkceParameters { + codeVerifier: string; + codeChallenge: string; +} + +/** + * Creates a randomly generated 32-byte array and encodes it using base64url. + * Generates the SHA256 hash value of the generated string, and returns both + * to be used as PKCE parameters. + * + * @returns {Promise} The generated PKCE parameters + */ +export async function generatePkceParameters(): Promise { + // Generate 32 random bytes + const randomBytes = new Uint8Array(32) + crypto.getRandomValues(randomBytes) + + // Convert raw bytes to string and encode with base64url + const verifier = base64UrlEncode(randomBytes) + + // Encode verifier as utf8, then digest with sha256. + // Convert sha256 bytes to string and encode with base64url to create challenge + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const digest = await crypto.subtle.digest("SHA-256", data) + const challenge = base64UrlEncode(new Uint8Array(digest)) + + // Return generated parameters + return { + codeVerifier: verifier, + codeChallenge: challenge, + }; +} diff --git a/src/main.ts b/src/main.ts index 660d621..10e8cde 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import type { MicropubConfigQueryResponse, MicropubUpdateActionRequest, } from "./micropub.js"; +import { generatePkceParameters } from "./lib/pkce.js"; interface MicropubOptions { me: string; @@ -26,6 +27,7 @@ interface MicropubOptions { state?: string; clientId?: string; redirectUri?: string; + codeVerifier?: string; } type MicropubOptionsKey = keyof MicropubOptions; @@ -41,6 +43,7 @@ const OPTIONS_KEYS: MicropubOptionsKey[] = [ "state", "clientId", "redirectUri", + "codeVerifier", ]; const DEFAULT_SETTINGS: MicropubOptions = { @@ -50,6 +53,7 @@ const DEFAULT_SETTINGS: MicropubOptions = { authEndpoint: "", tokenEndpoint: "", micropubEndpoint: "", + codeVerifier: undefined, }; interface MicropubRequestInit extends RequestInit { @@ -268,7 +272,7 @@ class Micropub { "tokenEndpoint", ]); - const { me, clientId, redirectUri, tokenEndpoint } = this.options; + const { me, clientId, redirectUri, tokenEndpoint, codeVerifier } = this.options; try { const data = { @@ -277,6 +281,7 @@ class Micropub { code, client_id: clientId, redirect_uri: redirectUri, + code_verifier: codeVerifier }; const res = await this.fetch({ @@ -314,11 +319,12 @@ class Micropub { /** * Get the authentication url based on the set options + * @param {boolean} usePkce Whether to use PKCE. If using PKCE, a code challenge is added to the auth URL. Default true. * @throws {MicropubError} If the options are not set * @return {Promise} The authentication url or false on missing options */ // @ts-expect-error - Error handling in a separate function - async getAuthUrl(): Promise { + async getAuthUrl(usePkce: boolean = true): Promise { this.checkRequiredOptions(["me", "state"]); try { const { me } = this.options; @@ -335,7 +341,7 @@ class Micropub { const { clientId, redirectUri, scope, state, authEndpoint } = this.options; - const authParams = { + const authParams: Record = { me, client_id: clientId, redirect_uri: redirectUri, @@ -344,6 +350,13 @@ class Micropub { state, }; + if (usePkce) { + const { codeChallenge, codeVerifier } = await generatePkceParameters(); + authParams.code_challenge = codeChallenge + authParams.code_challenge_method = "S256" + this.options = { codeVerifier } + } + return appendQueryString(authEndpoint, authParams as QueryVars); } catch (err) { this.handleError(err, "Error getting auth url"); diff --git a/src/tests/lib/base64url.test.ts b/src/tests/lib/base64url.test.ts new file mode 100644 index 0000000..58482c2 --- /dev/null +++ b/src/tests/lib/base64url.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { base64UrlEncode } from "../../lib/base64url"; + +describe("base64UrlEncode", () => { + const toBytes = (str: string) => new TextEncoder().encode(str) + const fromArr = (arr: number[]) => new Uint8Array(arr) + + // https://datatracker.ietf.org/doc/html/rfc4648 + // specifically section 5 + it('handles RFC 4648 test vectors correctly', () => { + assert.strictEqual(base64UrlEncode(toBytes('')), '') + assert.strictEqual(base64UrlEncode(toBytes('f')), 'Zg') + assert.strictEqual(base64UrlEncode(toBytes('foo')), 'Zm9v') + assert.strictEqual(base64UrlEncode(toBytes('foobar')), 'Zm9vYmFy') + }) + + it('strips padding characters (=)', () => { + assert.doesNotMatch(base64UrlEncode(toBytes('f')), /=+$/g) + assert.strictEqual(base64UrlEncode(toBytes('fo')), 'Zm8') // Zm8= in standard base64 + }) + + it('replaces + with -', () => { + const input = fromArr([251]) + assert.strictEqual(base64UrlEncode(input), '-w') // +w in standard base64 + }) + + it('replaces / with _', () => { + const input = fromArr([0xFB, 0xF0, 0xFF]) + assert.strictEqual(base64UrlEncode(input), '-_D_') // +/D/ in standard base64 + }) +}) \ No newline at end of file diff --git a/src/tests/lib/pkce.test.ts b/src/tests/lib/pkce.test.ts new file mode 100644 index 0000000..e75c2e2 --- /dev/null +++ b/src/tests/lib/pkce.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from "node:test"; +import { generatePkceParameters } from "../../lib/pkce"; +import assert from "node:assert"; +import { base64UrlEncode } from "../../lib/base64url"; + +describe("generatePkceParameters", () => { + it("Generates correct parameters", async () => { + const params = await generatePkceParameters(); + + // Check types + assert.strictEqual(typeof params.codeChallenge, "string"); + assert.strictEqual(typeof params.codeVerifier, "string"); + + // Check length of verifier for spec compliance + assert.ok( + params.codeVerifier.length >= 43 && params.codeVerifier.length <= 128, + ); + + // Check length for challenge (sha256 string as base64url) + assert.strictEqual(params.codeChallenge.length, 43); + + // Make sure they are not the same value + assert.notStrictEqual(params.codeChallenge, params.codeVerifier); + + // Make sure challenge the correct hashed value of verifier + const digest = await crypto.subtle.digest( + "SHA-256", + new Uint8Array(new TextEncoder().encode(params.codeVerifier)), + ); + + const expectChallenge = base64UrlEncode(new Uint8Array(digest)) + assert.strictEqual(params.codeChallenge, expectChallenge); + }); + + it("Generates unique parameters", async () => { + const one = await generatePkceParameters(); + const two = await generatePkceParameters(); + assert.notStrictEqual(one.codeVerifier, two.codeVerifier); + assert.notStrictEqual(one.codeChallenge, two.codeChallenge); + }); +}); diff --git a/src/tests/main.test.ts b/src/tests/main.test.ts index 487ece3..4b034d8 100644 --- a/src/tests/main.test.ts +++ b/src/tests/main.test.ts @@ -156,6 +156,33 @@ describe("Micropub", () => { assert.equal(parsedUrl.searchParams.get("state"), baseOptions.state); assert.equal(parsedUrl.searchParams.get("response_type"), "code"); assert.equal(parsedUrl.searchParams.get("scope"), "create delete update"); + assert.ok(parsedUrl.searchParams.get("code_challenge")) + assert.equal(parsedUrl.searchParams.get("code_challenge_method"), "S256"); + }); + + it("Get auth endpoint (PKCE off)", async () => { + mock.method(global, "fetch", () => ({ + status: 200, + text: () => serverData.pageHtml, + headers: new Headers({ "Content-Type": "text/html" }), + })); + const micropub = new Micropub(baseOptions); + const authUrlRes = await micropub.getAuthUrl(false); + const parsedUrl = new URL(authUrlRes); + + assert.equal(parsedUrl.host, "localhost:3313"); + assert.equal(parsedUrl.pathname, "/auth"); + assert.equal(parsedUrl.searchParams.get("me"), baseOptions.me); + assert.equal(parsedUrl.searchParams.get("client_id"), baseOptions.clientId); + assert.equal( + parsedUrl.searchParams.get("redirect_uri"), + baseOptions.redirectUri, + ); + assert.equal(parsedUrl.searchParams.get("state"), baseOptions.state); + assert.equal(parsedUrl.searchParams.get("response_type"), "code"); + assert.equal(parsedUrl.searchParams.get("scope"), "create delete update"); + assert.equal(parsedUrl.searchParams.get("code_challenge"), null) + assert.equal(parsedUrl.searchParams.get("code_challenge_method"), null); }); it("Verify token", async () => {