From ce13c130ff8f111f301dbc478484090fc4b2683c Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:07:24 +0800 Subject: [PATCH 1/6] feat(security): Add PKCE specific method for getting auth url; add code verifier functionality to getToken; add tests --- src/lib/base64url.ts | 271 +++++++++++++++++++++++++++++++++++++ src/lib/pkce.ts | 46 +++++++ src/main.ts | 32 ++++- src/tests/lib/pkce.test.ts | 46 +++++++ src/tests/main.test.ts | 29 ++++ 5 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 src/lib/base64url.ts create mode 100644 src/lib/pkce.ts create mode 100644 src/tests/lib/pkce.test.ts diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts new file mode 100644 index 0000000..01a551b --- /dev/null +++ b/src/lib/base64url.ts @@ -0,0 +1,271 @@ +/** + * Avoid modifying this file. It's part of + * https://github.com/supabase-community/base64url-js. Submit all fixes on + * that repo! + */ + +/** + * An array of characters that encode 6 bits into a Base64-URL alphabet + * character. + */ +const TO_BASE64URL = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split(""); + +/** + * An array of characters that can appear in a Base64-URL encoded string but + * should be ignored. + */ +const IGNORE_BASE64URL = " \t\n\r=".split(""); + +/** + * An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2 + * used to skip the character, or if -1 used to error out. + */ +const FROM_BASE64URL = (() => { + const charMap: number[] = new Array(128); + + for (let i = 0; i < charMap.length; i += 1) { + charMap[i] = -1; + } + + for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) { + charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2; + } + + for (let i = 0; i < TO_BASE64URL.length; i += 1) { + charMap[TO_BASE64URL[i].charCodeAt(0)] = i; + } + + return charMap; +})(); + +/** + * Converts a byte to a Base64-URL string. + * + * @param byte The byte to convert, or null to flush at the end of the byte sequence. + * @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next Base64 character when ready. + */ +export function byteToBase64URL( + byte: number | null, + state: { queue: number; queuedBits: number }, + emit: (char: string) => void, +) { + if (byte !== null) { + state.queue = (state.queue << 8) | byte; + state.queuedBits += 8; + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63; + emit(TO_BASE64URL[pos]); + state.queuedBits -= 6; + } + } else if (state.queuedBits > 0) { + state.queue = state.queue << (6 - state.queuedBits); + state.queuedBits = 6; + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63; + emit(TO_BASE64URL[pos]); + state.queuedBits -= 6; + } + } +} + +/** + * Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters. + * + * @param charCode The char code of the JavaScript string. + * @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next byte. + */ +export function byteFromBase64URL( + charCode: number, + state: { queue: number; queuedBits: number }, + emit: (byte: number) => void, +) { + const bits = FROM_BASE64URL[charCode]; + + if (bits > -1) { + // valid Base64-URL character + state.queue = (state.queue << 6) | bits; + state.queuedBits += 6; + + while (state.queuedBits >= 8) { + emit((state.queue >> (state.queuedBits - 8)) & 0xff); + state.queuedBits -= 8; + } + } else if (bits === -2) { + // ignore spaces, tabs, newlines, = + return; + } else { + throw new Error( + `Invalid Base64-URL character "${String.fromCharCode(charCode)}"`, + ); + } +} + +/** + * Converts a JavaScript string (which may include any valid character) into a + * Base64-URL encoded string. The string is first encoded in UTF-8 which is + * then encoded as Base64-URL. + * + * @param str The string to convert. + */ +export function stringToBase64URL(str: string) { + const base64: string[] = []; + + const emitter = (char: string) => { + base64.push(char); + }; + + const state = { queue: 0, queuedBits: 0 }; + + stringToUTF8(str, (byte: number) => { + byteToBase64URL(byte, state, emitter); + }); + + byteToBase64URL(null, state, emitter); + + return base64.join(""); +} + +/** + * Converts a Base64-URL encoded string into a JavaScript string. It is assumed + * that the underlying string has been encoded as UTF-8. + * + * @param str The Base64-URL encoded string. + */ +export function stringFromBase64URL(str: string) { + const conv: string[] = []; + + const utf8Emit = (codepoint: number) => { + conv.push(String.fromCodePoint(codepoint)); + }; + + const utf8State = { + utf8seq: 0, + codepoint: 0, + }; + + const b64State = { queue: 0, queuedBits: 0 }; + + const byteEmit = (byte: number) => { + stringFromUTF8(byte, utf8State, utf8Emit); + }; + + for (let i = 0; i < str.length; i += 1) { + byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit); + } + + return conv.join(""); +} + +/** + * Converts a Unicode codepoint to a multi-byte UTF-8 sequence. + * + * @param codepoint The Unicode codepoint. + * @param emit Function which will be called for each UTF-8 byte that represents the codepoint. + */ +export function codepointToUTF8( + codepoint: number, + emit: (byte: number) => void, +) { + if (codepoint <= 0x7f) { + emit(codepoint); + return; + } else if (codepoint <= 0x7ff) { + emit(0xc0 | (codepoint >> 6)); + emit(0x80 | (codepoint & 0x3f)); + return; + } else if (codepoint <= 0xffff) { + emit(0xe0 | (codepoint >> 12)); + emit(0x80 | ((codepoint >> 6) & 0x3f)); + emit(0x80 | (codepoint & 0x3f)); + return; + } else if (codepoint <= 0x10ffff) { + emit(0xf0 | (codepoint >> 18)); + emit(0x80 | ((codepoint >> 12) & 0x3f)); + emit(0x80 | ((codepoint >> 6) & 0x3f)); + emit(0x80 | (codepoint & 0x3f)); + return; + } + + throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`); +} + +/** + * Converts a JavaScript string to a sequence of UTF-8 bytes. + * + * @param str The string to convert to UTF-8. + * @param emit Function which will be called for each UTF-8 byte of the string. + */ +export function stringToUTF8(str: string, emit: (byte: number) => void) { + for (let i = 0; i < str.length; i += 1) { + let codepoint = str.charCodeAt(i); + + if (codepoint > 0xd7ff && codepoint <= 0xdbff) { + // most UTF-16 codepoints are Unicode codepoints, except values in this + // range where the next UTF-16 codepoint needs to be combined with the + // current one to get the Unicode codepoint + const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff; + const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff; + codepoint = (lowSurrogate | highSurrogate) + 0x10000; + i += 1; + } + + codepointToUTF8(codepoint, emit); + } +} + +/** + * Converts a UTF-8 byte to a Unicode codepoint. + * + * @param byte The UTF-8 byte next in the sequence. + * @param state The shared state between consecutive UTF-8 bytes in the + * sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`. + * @param emit Function which will be called for each codepoint. + */ +export function stringFromUTF8( + byte: number, + state: { utf8seq: number; codepoint: number }, + emit: (codepoint: number) => void, +) { + if (state.utf8seq === 0) { + if (byte <= 0x7f) { + emit(byte); + return; + } + + // count the number of 1 leading bits until you reach 0 + for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) { + if (((byte >> (7 - leadingBit)) & 1) === 0) { + state.utf8seq = leadingBit; + break; + } + } + + if (state.utf8seq === 2) { + state.codepoint = byte & 31; + } else if (state.utf8seq === 3) { + state.codepoint = byte & 15; + } else if (state.utf8seq === 4) { + state.codepoint = byte & 7; + } else { + throw new Error("Invalid UTF-8 sequence"); + } + + state.utf8seq -= 1; + } else if (state.utf8seq > 0) { + if (byte <= 0x7f) { + throw new Error("Invalid UTF-8 sequence"); + } + + state.codepoint = (state.codepoint << 6) | (byte & 63); + state.utf8seq -= 1; + + if (state.utf8seq === 0) { + emit(state.codepoint); + } + } +} diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 0000000..ce3af6f --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,46 @@ +import { TextEncoder } from "node:util"; +import { stringToBase64URL } 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 + let verifier = ""; + for (const byte of randomBytes) { + verifier += String.fromCharCode(byte); + } + + verifier = stringToBase64URL(verifier); + + // Encode verifier as utf8, then digest with sha256. Convert sha256 + // bytes to string and encode with base64url as before + const utf8 = new Uint8Array(new TextEncoder().encode(verifier)); // wrapping required for TS + const digest = await crypto.subtle.digest("SHA-256", utf8); + + let challenge = ""; + for (const byte of new Uint8Array(digest)) { + challenge += String.fromCharCode(byte); + } + + challenge = stringToBase64URL(challenge); + + // Return generated parameters + return { + codeVerifier: verifier, + codeChallenge: challenge, + }; +} diff --git a/src/main.ts b/src/main.ts index 660d621..aebfd12 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; @@ -43,6 +44,11 @@ const OPTIONS_KEYS: MicropubOptionsKey[] = [ "redirectUri", ]; +interface PkceEnabledAuthUrl { + url: string, + codeVerifier: string +} + const DEFAULT_SETTINGS: MicropubOptions = { me: "", scope: "create delete update", @@ -256,11 +262,12 @@ class Micropub { /** * Exchanges a code for an access token * @param {string} code A code received from the auth endpoint + * @param {string?} codeVerifier The code verifier for PKCE (if using PKCE); default undefined * @throws {MicropubError} If the token request fails * @return {Promise} Promise which resolves with the access token on success */ // @ts-expect-error - Error handling in a separate function - async getToken(code: string): Promise { + async getToken(code: string, codeVerifier?: string): Promise { this.checkRequiredOptions([ "me", "clientId", @@ -277,6 +284,7 @@ class Micropub { code, client_id: clientId, redirect_uri: redirectUri, + code_verifier: codeVerifier }; const res = await this.fetch({ @@ -350,6 +358,28 @@ class Micropub { } } + /** + * Get the authentication url based on the set options; generates random parameters for + * PKCE (Proof-Key for Code Exchange) and attaches them to the URL. See {@link getAuthUrl}. + * + * @throws {MicropubError} If the options are not set + * @return {Promise} The authentication url or false on missing options + */ + async getAuthUrlPkce(): Promise { + const url = await this.getAuthUrl(); + + const params = await generatePkceParameters(); + const pkceParams = { + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + }; + + return { + url: appendQueryString(url, pkceParams), + codeVerifier: params.codeVerifier + } + } + /** * Verify the stored access token * @throws {MicropubError} If the token verification fails diff --git a/src/tests/lib/pkce.test.ts b/src/tests/lib/pkce.test.ts new file mode 100644 index 0000000..1e2b5c4 --- /dev/null +++ b/src/tests/lib/pkce.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from "node:test"; +import { generatePkceParameters } from "../../lib/pkce"; +import assert from "node:assert"; +import { stringToBase64URL } 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)), + ); + + let expectChallenge = ""; + for (const byte of new Uint8Array(digest)) { + expectChallenge += String.fromCharCode(byte); + } + + expectChallenge = stringToBase64URL(expectChallenge); + 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..d50d500 100644 --- a/src/tests/main.test.ts +++ b/src/tests/main.test.ts @@ -158,6 +158,35 @@ describe("Micropub", () => { assert.equal(parsedUrl.searchParams.get("scope"), "create delete update"); }); + it("Get auth endpoint with PKCE", 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.getAuthUrlPkce(); + + assert.ok(authUrlRes.url) + assert.ok(authUrlRes.codeVerifier) + + const parsedUrl = new URL(authUrlRes.url); + + 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_method"), "S256"); + assert.ok(parsedUrl.searchParams.get("code_challenge")) + }) + it("Verify token", async () => { mock.method(global, "fetch", () => ({ status: 200, From c8d053b1fb62924df8e87fdb8f4e08ffba34fe7d Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:29:30 +0800 Subject: [PATCH 2/6] feat(cleanup): switch base64url-js with custom base64url code; add test for base64url code; update pkce generation --- src/lib/base64url.ts | 279 +------------------------------- src/lib/pkce.ts | 32 ++-- src/tests/lib/base64url.test.ts | 32 ++++ src/tests/lib/pkce.test.ts | 9 +- 4 files changed, 53 insertions(+), 299 deletions(-) create mode 100644 src/tests/lib/base64url.test.ts diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts index 01a551b..1047e6e 100644 --- a/src/lib/base64url.ts +++ b/src/lib/base64url.ts @@ -1,271 +1,8 @@ -/** - * Avoid modifying this file. It's part of - * https://github.com/supabase-community/base64url-js. Submit all fixes on - * that repo! - */ - -/** - * An array of characters that encode 6 bits into a Base64-URL alphabet - * character. - */ -const TO_BASE64URL = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split(""); - -/** - * An array of characters that can appear in a Base64-URL encoded string but - * should be ignored. - */ -const IGNORE_BASE64URL = " \t\n\r=".split(""); - -/** - * An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2 - * used to skip the character, or if -1 used to error out. - */ -const FROM_BASE64URL = (() => { - const charMap: number[] = new Array(128); - - for (let i = 0; i < charMap.length; i += 1) { - charMap[i] = -1; - } - - for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) { - charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2; - } - - for (let i = 0; i < TO_BASE64URL.length; i += 1) { - charMap[TO_BASE64URL[i].charCodeAt(0)] = i; - } - - return charMap; -})(); - -/** - * Converts a byte to a Base64-URL string. - * - * @param byte The byte to convert, or null to flush at the end of the byte sequence. - * @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. - * @param emit A function called with the next Base64 character when ready. - */ -export function byteToBase64URL( - byte: number | null, - state: { queue: number; queuedBits: number }, - emit: (char: string) => void, -) { - if (byte !== null) { - state.queue = (state.queue << 8) | byte; - state.queuedBits += 8; - - while (state.queuedBits >= 6) { - const pos = (state.queue >> (state.queuedBits - 6)) & 63; - emit(TO_BASE64URL[pos]); - state.queuedBits -= 6; - } - } else if (state.queuedBits > 0) { - state.queue = state.queue << (6 - state.queuedBits); - state.queuedBits = 6; - - while (state.queuedBits >= 6) { - const pos = (state.queue >> (state.queuedBits - 6)) & 63; - emit(TO_BASE64URL[pos]); - state.queuedBits -= 6; - } - } -} - -/** - * Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters. - * - * @param charCode The char code of the JavaScript string. - * @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. - * @param emit A function called with the next byte. - */ -export function byteFromBase64URL( - charCode: number, - state: { queue: number; queuedBits: number }, - emit: (byte: number) => void, -) { - const bits = FROM_BASE64URL[charCode]; - - if (bits > -1) { - // valid Base64-URL character - state.queue = (state.queue << 6) | bits; - state.queuedBits += 6; - - while (state.queuedBits >= 8) { - emit((state.queue >> (state.queuedBits - 8)) & 0xff); - state.queuedBits -= 8; - } - } else if (bits === -2) { - // ignore spaces, tabs, newlines, = - return; - } else { - throw new Error( - `Invalid Base64-URL character "${String.fromCharCode(charCode)}"`, - ); - } -} - -/** - * Converts a JavaScript string (which may include any valid character) into a - * Base64-URL encoded string. The string is first encoded in UTF-8 which is - * then encoded as Base64-URL. - * - * @param str The string to convert. - */ -export function stringToBase64URL(str: string) { - const base64: string[] = []; - - const emitter = (char: string) => { - base64.push(char); - }; - - const state = { queue: 0, queuedBits: 0 }; - - stringToUTF8(str, (byte: number) => { - byteToBase64URL(byte, state, emitter); - }); - - byteToBase64URL(null, state, emitter); - - return base64.join(""); -} - -/** - * Converts a Base64-URL encoded string into a JavaScript string. It is assumed - * that the underlying string has been encoded as UTF-8. - * - * @param str The Base64-URL encoded string. - */ -export function stringFromBase64URL(str: string) { - const conv: string[] = []; - - const utf8Emit = (codepoint: number) => { - conv.push(String.fromCodePoint(codepoint)); - }; - - const utf8State = { - utf8seq: 0, - codepoint: 0, - }; - - const b64State = { queue: 0, queuedBits: 0 }; - - const byteEmit = (byte: number) => { - stringFromUTF8(byte, utf8State, utf8Emit); - }; - - for (let i = 0; i < str.length; i += 1) { - byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit); - } - - return conv.join(""); -} - -/** - * Converts a Unicode codepoint to a multi-byte UTF-8 sequence. - * - * @param codepoint The Unicode codepoint. - * @param emit Function which will be called for each UTF-8 byte that represents the codepoint. - */ -export function codepointToUTF8( - codepoint: number, - emit: (byte: number) => void, -) { - if (codepoint <= 0x7f) { - emit(codepoint); - return; - } else if (codepoint <= 0x7ff) { - emit(0xc0 | (codepoint >> 6)); - emit(0x80 | (codepoint & 0x3f)); - return; - } else if (codepoint <= 0xffff) { - emit(0xe0 | (codepoint >> 12)); - emit(0x80 | ((codepoint >> 6) & 0x3f)); - emit(0x80 | (codepoint & 0x3f)); - return; - } else if (codepoint <= 0x10ffff) { - emit(0xf0 | (codepoint >> 18)); - emit(0x80 | ((codepoint >> 12) & 0x3f)); - emit(0x80 | ((codepoint >> 6) & 0x3f)); - emit(0x80 | (codepoint & 0x3f)); - return; - } - - throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`); -} - -/** - * Converts a JavaScript string to a sequence of UTF-8 bytes. - * - * @param str The string to convert to UTF-8. - * @param emit Function which will be called for each UTF-8 byte of the string. - */ -export function stringToUTF8(str: string, emit: (byte: number) => void) { - for (let i = 0; i < str.length; i += 1) { - let codepoint = str.charCodeAt(i); - - if (codepoint > 0xd7ff && codepoint <= 0xdbff) { - // most UTF-16 codepoints are Unicode codepoints, except values in this - // range where the next UTF-16 codepoint needs to be combined with the - // current one to get the Unicode codepoint - const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff; - const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff; - codepoint = (lowSurrogate | highSurrogate) + 0x10000; - i += 1; - } - - codepointToUTF8(codepoint, emit); - } -} - -/** - * Converts a UTF-8 byte to a Unicode codepoint. - * - * @param byte The UTF-8 byte next in the sequence. - * @param state The shared state between consecutive UTF-8 bytes in the - * sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`. - * @param emit Function which will be called for each codepoint. - */ -export function stringFromUTF8( - byte: number, - state: { utf8seq: number; codepoint: number }, - emit: (codepoint: number) => void, -) { - if (state.utf8seq === 0) { - if (byte <= 0x7f) { - emit(byte); - return; - } - - // count the number of 1 leading bits until you reach 0 - for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) { - if (((byte >> (7 - leadingBit)) & 1) === 0) { - state.utf8seq = leadingBit; - break; - } - } - - if (state.utf8seq === 2) { - state.codepoint = byte & 31; - } else if (state.utf8seq === 3) { - state.codepoint = byte & 15; - } else if (state.utf8seq === 4) { - state.codepoint = byte & 7; - } else { - throw new Error("Invalid UTF-8 sequence"); - } - - state.utf8seq -= 1; - } else if (state.utf8seq > 0) { - if (byte <= 0x7f) { - throw new Error("Invalid UTF-8 sequence"); - } - - state.codepoint = (state.codepoint << 6) | (byte & 63); - state.utf8seq -= 1; - - if (state.utf8seq === 0) { - emit(state.codepoint); - } - } -} +export function base64UrlEncode(bytes: Uint8Array): string { + const binaryString = String.fromCharCode(...bytes) + + return btoa(binaryString) + .replace(/\+/, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} \ No newline at end of file diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts index ce3af6f..7caa8c9 100644 --- a/src/lib/pkce.ts +++ b/src/lib/pkce.ts @@ -1,5 +1,5 @@ import { TextEncoder } from "node:util"; -import { stringToBase64URL } from "./base64url"; +import { base64UrlEncode } from "./base64url"; export interface PkceParameters { codeVerifier: string; @@ -15,28 +15,18 @@ export interface PkceParameters { */ export async function generatePkceParameters(): Promise { // Generate 32 random bytes - const randomBytes = new Uint8Array(32); - crypto.getRandomValues(randomBytes); + const randomBytes = new Uint8Array(32) + crypto.getRandomValues(randomBytes) // Convert raw bytes to string and encode with base64url - let verifier = ""; - for (const byte of randomBytes) { - verifier += String.fromCharCode(byte); - } - - verifier = stringToBase64URL(verifier); - - // Encode verifier as utf8, then digest with sha256. Convert sha256 - // bytes to string and encode with base64url as before - const utf8 = new Uint8Array(new TextEncoder().encode(verifier)); // wrapping required for TS - const digest = await crypto.subtle.digest("SHA-256", utf8); - - let challenge = ""; - for (const byte of new Uint8Array(digest)) { - challenge += String.fromCharCode(byte); - } - - challenge = stringToBase64URL(challenge); + 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 { 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 index 1e2b5c4..e75c2e2 100644 --- a/src/tests/lib/pkce.test.ts +++ b/src/tests/lib/pkce.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import { generatePkceParameters } from "../../lib/pkce"; import assert from "node:assert"; -import { stringToBase64URL } from "../../lib/base64url"; +import { base64UrlEncode } from "../../lib/base64url"; describe("generatePkceParameters", () => { it("Generates correct parameters", async () => { @@ -28,12 +28,7 @@ describe("generatePkceParameters", () => { new Uint8Array(new TextEncoder().encode(params.codeVerifier)), ); - let expectChallenge = ""; - for (const byte of new Uint8Array(digest)) { - expectChallenge += String.fromCharCode(byte); - } - - expectChallenge = stringToBase64URL(expectChallenge); + const expectChallenge = base64UrlEncode(new Uint8Array(digest)) assert.strictEqual(params.codeChallenge, expectChallenge); }); From 0ec166e03a7cc769339215fe00659bf502fcdcf1 Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:45:59 +0800 Subject: [PATCH 3/6] feat(cleanup): stop importing TextEncoder, since it is available in the global namespace --- src/lib/pkce.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts index 7caa8c9..87d31f9 100644 --- a/src/lib/pkce.ts +++ b/src/lib/pkce.ts @@ -1,4 +1,3 @@ -import { TextEncoder } from "node:util"; import { base64UrlEncode } from "./base64url"; export interface PkceParameters { From a02a1a6d9a92e258a1fcc26f8b823403e6e70a45 Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:16:26 +0800 Subject: [PATCH 4/6] fix(bug): add `g` to first regex in order to replace all instances of `+` instead of just the first --- src/lib/base64url.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts index 1047e6e..4cac0e7 100644 --- a/src/lib/base64url.ts +++ b/src/lib/base64url.ts @@ -2,7 +2,7 @@ export function base64UrlEncode(bytes: Uint8Array): string { const binaryString = String.fromCharCode(...bytes) return btoa(binaryString) - .replace(/\+/, "-") + .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, "") } \ No newline at end of file From 51b207e9bc809312ff2b8d07893326173aad98d8 Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:04:03 +0800 Subject: [PATCH 5/6] feat(cleanup): make PKCE default for getAuthUrl and remove second auth URL method; add code verifier to options and use it automatically if it exists --- src/main.ts | 47 ++++++++++++++---------------------------- src/tests/main.test.ts | 22 +++++++++----------- 2 files changed, 25 insertions(+), 44 deletions(-) diff --git a/src/main.ts b/src/main.ts index aebfd12..bb15b25 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,6 +27,7 @@ interface MicropubOptions { state?: string; clientId?: string; redirectUri?: string; + codeVerifier?: string; } type MicropubOptionsKey = keyof MicropubOptions; @@ -42,13 +43,9 @@ const OPTIONS_KEYS: MicropubOptionsKey[] = [ "state", "clientId", "redirectUri", + "codeVerifier", ]; -interface PkceEnabledAuthUrl { - url: string, - codeVerifier: string -} - const DEFAULT_SETTINGS: MicropubOptions = { me: "", scope: "create delete update", @@ -56,6 +53,7 @@ const DEFAULT_SETTINGS: MicropubOptions = { authEndpoint: "", tokenEndpoint: "", micropubEndpoint: "", + codeVerifier: undefined, }; interface MicropubRequestInit extends RequestInit { @@ -262,12 +260,11 @@ class Micropub { /** * Exchanges a code for an access token * @param {string} code A code received from the auth endpoint - * @param {string?} codeVerifier The code verifier for PKCE (if using PKCE); default undefined * @throws {MicropubError} If the token request fails * @return {Promise} Promise which resolves with the access token on success */ // @ts-expect-error - Error handling in a separate function - async getToken(code: string, codeVerifier?: string): Promise { + async getToken(code: string): Promise { this.checkRequiredOptions([ "me", "clientId", @@ -275,7 +272,7 @@ class Micropub { "tokenEndpoint", ]); - const { me, clientId, redirectUri, tokenEndpoint } = this.options; + const { me, clientId, redirectUri, tokenEndpoint, codeVerifier } = this.options; try { const data = { @@ -322,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; @@ -343,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, @@ -352,34 +350,19 @@ class Micropub { state, }; + if (usePkce) { + const { codeChallenge, codeVerifier } = await generatePkceParameters(); + authParams.code_challenge = codeChallenge + authParams.code_challenge_method = "S256" + this.options.codeVerifier = codeVerifier + } + return appendQueryString(authEndpoint, authParams as QueryVars); } catch (err) { this.handleError(err, "Error getting auth url"); } } - /** - * Get the authentication url based on the set options; generates random parameters for - * PKCE (Proof-Key for Code Exchange) and attaches them to the URL. See {@link getAuthUrl}. - * - * @throws {MicropubError} If the options are not set - * @return {Promise} The authentication url or false on missing options - */ - async getAuthUrlPkce(): Promise { - const url = await this.getAuthUrl(); - - const params = await generatePkceParameters(); - const pkceParams = { - code_challenge: params.codeChallenge, - code_challenge_method: "S256", - }; - - return { - url: appendQueryString(url, pkceParams), - codeVerifier: params.codeVerifier - } - } - /** * Verify the stored access token * @throws {MicropubError} If the token verification fails diff --git a/src/tests/main.test.ts b/src/tests/main.test.ts index d50d500..4b034d8 100644 --- a/src/tests/main.test.ts +++ b/src/tests/main.test.ts @@ -156,21 +156,19 @@ 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 with PKCE", async () => { - mock.method(global, "fetch", () => ({ + + 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.getAuthUrlPkce(); - - assert.ok(authUrlRes.url) - assert.ok(authUrlRes.codeVerifier) - - const parsedUrl = new URL(authUrlRes.url); + const authUrlRes = await micropub.getAuthUrl(false); + const parsedUrl = new URL(authUrlRes); assert.equal(parsedUrl.host, "localhost:3313"); assert.equal(parsedUrl.pathname, "/auth"); @@ -183,9 +181,9 @@ 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.equal(parsedUrl.searchParams.get("code_challenge_method"), "S256"); - assert.ok(parsedUrl.searchParams.get("code_challenge")) - }) + assert.equal(parsedUrl.searchParams.get("code_challenge"), null) + assert.equal(parsedUrl.searchParams.get("code_challenge_method"), null); + }); it("Verify token", async () => { mock.method(global, "fetch", () => ({ From 900f2fb9c4f4648cd8f712334172c814b7ccd884 Mon Sep 17 00:00:00 2001 From: Jacob Andersen <19645494+jacobsandersen@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:44:48 +0800 Subject: [PATCH 6/6] fix(bug): update getAuthUrl() to properly update options object when using PKCE --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index bb15b25..10e8cde 100644 --- a/src/main.ts +++ b/src/main.ts @@ -354,7 +354,7 @@ class Micropub { const { codeChallenge, codeVerifier } = await generatePkceParameters(); authParams.code_challenge = codeChallenge authParams.code_challenge_method = "S256" - this.options.codeVerifier = codeVerifier + this.options = { codeVerifier } } return appendQueryString(authEndpoint, authParams as QueryVars);