Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/base64url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function base64UrlEncode(bytes: Uint8Array): string {
const binaryString = String.fromCharCode(...bytes)

return btoa(binaryString)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
35 changes: 35 additions & 0 deletions src/lib/pkce.ts
Original file line number Diff line number Diff line change
@@ -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<PkceParameters>} The generated PKCE parameters
*/
export async function generatePkceParameters(): Promise<PkceParameters> {
// 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,
};
}
19 changes: 16 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
MicropubConfigQueryResponse,
MicropubUpdateActionRequest,
} from "./micropub.js";
import { generatePkceParameters } from "./lib/pkce.js";

interface MicropubOptions {
me: string;
Expand All @@ -26,6 +27,7 @@ interface MicropubOptions {
state?: string;
clientId?: string;
redirectUri?: string;
codeVerifier?: string;
}

type MicropubOptionsKey = keyof MicropubOptions;
Expand All @@ -41,6 +43,7 @@ const OPTIONS_KEYS: MicropubOptionsKey[] = [
"state",
"clientId",
"redirectUri",
"codeVerifier",
];

const DEFAULT_SETTINGS: MicropubOptions = {
Expand All @@ -50,6 +53,7 @@ const DEFAULT_SETTINGS: MicropubOptions = {
authEndpoint: "",
tokenEndpoint: "",
micropubEndpoint: "",
codeVerifier: undefined,
};

interface MicropubRequestInit extends RequestInit {
Expand Down Expand Up @@ -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 = {
Expand All @@ -277,6 +281,7 @@ class Micropub {
code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier
};

const res = await this.fetch({
Expand Down Expand Up @@ -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<string>} The authentication url or false on missing options
*/
// @ts-expect-error - Error handling in a separate function
async getAuthUrl(): Promise<string> {
async getAuthUrl(usePkce: boolean = true): Promise<string> {
this.checkRequiredOptions(["me", "state"]);
try {
const { me } = this.options;
Expand All @@ -335,7 +341,7 @@ class Micropub {
const { clientId, redirectUri, scope, state, authEndpoint } =
this.options;

const authParams = {
const authParams: Record<string, unknown> = {
me,
client_id: clientId,
redirect_uri: redirectUri,
Expand All @@ -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");
Expand Down
32 changes: 32 additions & 0 deletions src/tests/lib/base64url.test.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
41 changes: 41 additions & 0 deletions src/tests/lib/pkce.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions src/tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down