Skip to content

Commit eefb315

Browse files
feat(security): Add PKCE specific method for getting auth url; add code verifier functionality to getToken; add tests
1 parent fe949b1 commit eefb315

File tree

5 files changed

+424
-2
lines changed

5 files changed

+424
-2
lines changed

src/lib/base64url.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/**
2+
* Avoid modifying this file. It's part of
3+
* https://github.com/supabase-community/base64url-js. Submit all fixes on
4+
* that repo!
5+
*/
6+
7+
/**
8+
* An array of characters that encode 6 bits into a Base64-URL alphabet
9+
* character.
10+
*/
11+
const TO_BASE64URL =
12+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("");
13+
14+
/**
15+
* An array of characters that can appear in a Base64-URL encoded string but
16+
* should be ignored.
17+
*/
18+
const IGNORE_BASE64URL = " \t\n\r=".split("");
19+
20+
/**
21+
* An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2
22+
* used to skip the character, or if -1 used to error out.
23+
*/
24+
const FROM_BASE64URL = (() => {
25+
const charMap: number[] = new Array(128);
26+
27+
for (let i = 0; i < charMap.length; i += 1) {
28+
charMap[i] = -1;
29+
}
30+
31+
for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) {
32+
charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2;
33+
}
34+
35+
for (let i = 0; i < TO_BASE64URL.length; i += 1) {
36+
charMap[TO_BASE64URL[i].charCodeAt(0)] = i;
37+
}
38+
39+
return charMap;
40+
})();
41+
42+
/**
43+
* Converts a byte to a Base64-URL string.
44+
*
45+
* @param byte The byte to convert, or null to flush at the end of the byte sequence.
46+
* @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`.
47+
* @param emit A function called with the next Base64 character when ready.
48+
*/
49+
export function byteToBase64URL(
50+
byte: number | null,
51+
state: { queue: number; queuedBits: number },
52+
emit: (char: string) => void,
53+
) {
54+
if (byte !== null) {
55+
state.queue = (state.queue << 8) | byte;
56+
state.queuedBits += 8;
57+
58+
while (state.queuedBits >= 6) {
59+
const pos = (state.queue >> (state.queuedBits - 6)) & 63;
60+
emit(TO_BASE64URL[pos]);
61+
state.queuedBits -= 6;
62+
}
63+
} else if (state.queuedBits > 0) {
64+
state.queue = state.queue << (6 - state.queuedBits);
65+
state.queuedBits = 6;
66+
67+
while (state.queuedBits >= 6) {
68+
const pos = (state.queue >> (state.queuedBits - 6)) & 63;
69+
emit(TO_BASE64URL[pos]);
70+
state.queuedBits -= 6;
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters.
77+
*
78+
* @param charCode The char code of the JavaScript string.
79+
* @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`.
80+
* @param emit A function called with the next byte.
81+
*/
82+
export function byteFromBase64URL(
83+
charCode: number,
84+
state: { queue: number; queuedBits: number },
85+
emit: (byte: number) => void,
86+
) {
87+
const bits = FROM_BASE64URL[charCode];
88+
89+
if (bits > -1) {
90+
// valid Base64-URL character
91+
state.queue = (state.queue << 6) | bits;
92+
state.queuedBits += 6;
93+
94+
while (state.queuedBits >= 8) {
95+
emit((state.queue >> (state.queuedBits - 8)) & 0xff);
96+
state.queuedBits -= 8;
97+
}
98+
} else if (bits === -2) {
99+
// ignore spaces, tabs, newlines, =
100+
return;
101+
} else {
102+
throw new Error(
103+
`Invalid Base64-URL character "${String.fromCharCode(charCode)}"`,
104+
);
105+
}
106+
}
107+
108+
/**
109+
* Converts a JavaScript string (which may include any valid character) into a
110+
* Base64-URL encoded string. The string is first encoded in UTF-8 which is
111+
* then encoded as Base64-URL.
112+
*
113+
* @param str The string to convert.
114+
*/
115+
export function stringToBase64URL(str: string) {
116+
const base64: string[] = [];
117+
118+
const emitter = (char: string) => {
119+
base64.push(char);
120+
};
121+
122+
const state = { queue: 0, queuedBits: 0 };
123+
124+
stringToUTF8(str, (byte: number) => {
125+
byteToBase64URL(byte, state, emitter);
126+
});
127+
128+
byteToBase64URL(null, state, emitter);
129+
130+
return base64.join("");
131+
}
132+
133+
/**
134+
* Converts a Base64-URL encoded string into a JavaScript string. It is assumed
135+
* that the underlying string has been encoded as UTF-8.
136+
*
137+
* @param str The Base64-URL encoded string.
138+
*/
139+
export function stringFromBase64URL(str: string) {
140+
const conv: string[] = [];
141+
142+
const utf8Emit = (codepoint: number) => {
143+
conv.push(String.fromCodePoint(codepoint));
144+
};
145+
146+
const utf8State = {
147+
utf8seq: 0,
148+
codepoint: 0,
149+
};
150+
151+
const b64State = { queue: 0, queuedBits: 0 };
152+
153+
const byteEmit = (byte: number) => {
154+
stringFromUTF8(byte, utf8State, utf8Emit);
155+
};
156+
157+
for (let i = 0; i < str.length; i += 1) {
158+
byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit);
159+
}
160+
161+
return conv.join("");
162+
}
163+
164+
/**
165+
* Converts a Unicode codepoint to a multi-byte UTF-8 sequence.
166+
*
167+
* @param codepoint The Unicode codepoint.
168+
* @param emit Function which will be called for each UTF-8 byte that represents the codepoint.
169+
*/
170+
export function codepointToUTF8(
171+
codepoint: number,
172+
emit: (byte: number) => void,
173+
) {
174+
if (codepoint <= 0x7f) {
175+
emit(codepoint);
176+
return;
177+
} else if (codepoint <= 0x7ff) {
178+
emit(0xc0 | (codepoint >> 6));
179+
emit(0x80 | (codepoint & 0x3f));
180+
return;
181+
} else if (codepoint <= 0xffff) {
182+
emit(0xe0 | (codepoint >> 12));
183+
emit(0x80 | ((codepoint >> 6) & 0x3f));
184+
emit(0x80 | (codepoint & 0x3f));
185+
return;
186+
} else if (codepoint <= 0x10ffff) {
187+
emit(0xf0 | (codepoint >> 18));
188+
emit(0x80 | ((codepoint >> 12) & 0x3f));
189+
emit(0x80 | ((codepoint >> 6) & 0x3f));
190+
emit(0x80 | (codepoint & 0x3f));
191+
return;
192+
}
193+
194+
throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`);
195+
}
196+
197+
/**
198+
* Converts a JavaScript string to a sequence of UTF-8 bytes.
199+
*
200+
* @param str The string to convert to UTF-8.
201+
* @param emit Function which will be called for each UTF-8 byte of the string.
202+
*/
203+
export function stringToUTF8(str: string, emit: (byte: number) => void) {
204+
for (let i = 0; i < str.length; i += 1) {
205+
let codepoint = str.charCodeAt(i);
206+
207+
if (codepoint > 0xd7ff && codepoint <= 0xdbff) {
208+
// most UTF-16 codepoints are Unicode codepoints, except values in this
209+
// range where the next UTF-16 codepoint needs to be combined with the
210+
// current one to get the Unicode codepoint
211+
const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff;
212+
const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff;
213+
codepoint = (lowSurrogate | highSurrogate) + 0x10000;
214+
i += 1;
215+
}
216+
217+
codepointToUTF8(codepoint, emit);
218+
}
219+
}
220+
221+
/**
222+
* Converts a UTF-8 byte to a Unicode codepoint.
223+
*
224+
* @param byte The UTF-8 byte next in the sequence.
225+
* @param state The shared state between consecutive UTF-8 bytes in the
226+
* sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`.
227+
* @param emit Function which will be called for each codepoint.
228+
*/
229+
export function stringFromUTF8(
230+
byte: number,
231+
state: { utf8seq: number; codepoint: number },
232+
emit: (codepoint: number) => void,
233+
) {
234+
if (state.utf8seq === 0) {
235+
if (byte <= 0x7f) {
236+
emit(byte);
237+
return;
238+
}
239+
240+
// count the number of 1 leading bits until you reach 0
241+
for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) {
242+
if (((byte >> (7 - leadingBit)) & 1) === 0) {
243+
state.utf8seq = leadingBit;
244+
break;
245+
}
246+
}
247+
248+
if (state.utf8seq === 2) {
249+
state.codepoint = byte & 31;
250+
} else if (state.utf8seq === 3) {
251+
state.codepoint = byte & 15;
252+
} else if (state.utf8seq === 4) {
253+
state.codepoint = byte & 7;
254+
} else {
255+
throw new Error("Invalid UTF-8 sequence");
256+
}
257+
258+
state.utf8seq -= 1;
259+
} else if (state.utf8seq > 0) {
260+
if (byte <= 0x7f) {
261+
throw new Error("Invalid UTF-8 sequence");
262+
}
263+
264+
state.codepoint = (state.codepoint << 6) | (byte & 63);
265+
state.utf8seq -= 1;
266+
267+
if (state.utf8seq === 0) {
268+
emit(state.codepoint);
269+
}
270+
}
271+
}

src/lib/pkce.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { TextEncoder } from "node:util";
2+
import { stringToBase64URL } from "./base64url";
3+
4+
export interface PkceParameters {
5+
codeVerifier: string;
6+
codeChallenge: string;
7+
}
8+
9+
/**
10+
* Creates a randomly generated 32-byte array and encodes it using base64url.
11+
* Generates the SHA256 hash value of the generated string, and returns both
12+
* to be used as PKCE parameters.
13+
*
14+
* @returns {Promise<PkceParameters>} The generated PKCE parameters
15+
*/
16+
export async function generatePkceParameters(): Promise<PkceParameters> {
17+
// Generate 32 random bytes
18+
const randomBytes = new Uint8Array(32);
19+
crypto.getRandomValues(randomBytes);
20+
21+
// Convert raw bytes to string and encode with base64url
22+
let verifier = "";
23+
for (const byte of randomBytes) {
24+
verifier += String.fromCharCode(byte);
25+
}
26+
27+
verifier = stringToBase64URL(verifier);
28+
29+
// Encode verifier as utf8, then digest with sha256. Convert sha256
30+
// bytes to string and encode with base64url as before
31+
const utf8 = new Uint8Array(new TextEncoder().encode(verifier)); // wrapping required for TS
32+
const digest = await crypto.subtle.digest("SHA-256", utf8);
33+
34+
let challenge = "";
35+
for (const byte of new Uint8Array(digest)) {
36+
challenge += String.fromCharCode(byte);
37+
}
38+
39+
challenge = stringToBase64URL(challenge);
40+
41+
// Return generated parameters
42+
return {
43+
codeVerifier: verifier,
44+
codeChallenge: challenge,
45+
};
46+
}

src/main.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
MicropubConfigQueryResponse,
1515
MicropubUpdateActionRequest,
1616
} from "./micropub.js";
17+
import { generatePkceParameters } from "./lib/pkce.js";
1718

1819
interface MicropubOptions {
1920
me: string;
@@ -43,6 +44,11 @@ const OPTIONS_KEYS: MicropubOptionsKey[] = [
4344
"redirectUri",
4445
]
4546

47+
interface PkceEnabledAuthUrl {
48+
url: string,
49+
codeVerifier: string
50+
}
51+
4652
const DEFAULT_SETTINGS: MicropubOptions = {
4753
me: "",
4854
scope: "create delete update",
@@ -86,7 +92,7 @@ class Micropub {
8692
* @param options Object of options to set.
8793
*/
8894
set options(options: Partial<MicropubOptions>) {
89-
95+
9096
for (const key in options) {
9197
if (!OPTIONS_KEYS.includes(key as MicropubOptionsKey)) {
9298
throw new MicropubError(`Unknown option: ${key}`);
@@ -249,11 +255,12 @@ class Micropub {
249255
/**
250256
* Exchanges a code for an access token
251257
* @param {string} code A code received from the auth endpoint
258+
* @param {string?} codeVerifier The code verifier for PKCE (if using PKCE); default undefined
252259
* @throws {MicropubError} If the token request fails
253260
* @return {Promise<string>} Promise which resolves with the access token on success
254261
*/
255262
// @ts-expect-error - Error handling in a separate function
256-
async getToken(code: string): Promise<string> {
263+
async getToken(code: string, codeVerifier?: string): Promise<string> {
257264
this.checkRequiredOptions([
258265
"me",
259266
"clientId",
@@ -270,6 +277,7 @@ class Micropub {
270277
code,
271278
client_id: clientId,
272279
redirect_uri: redirectUri,
280+
code_verifier: codeVerifier
273281
};
274282

275283
const res = await this.fetch({
@@ -343,6 +351,28 @@ class Micropub {
343351
}
344352
}
345353

354+
/**
355+
* Get the authentication url based on the set options; generates random parameters for
356+
* PKCE (Proof-Key for Code Exchange) and attaches them to the URL. See {@link getAuthUrl}.
357+
*
358+
* @throws {MicropubError} If the options are not set
359+
* @return {Promise<string>} The authentication url or false on missing options
360+
*/
361+
async getAuthUrlPkce(): Promise<PkceEnabledAuthUrl> {
362+
const url = await this.getAuthUrl();
363+
364+
const params = await generatePkceParameters();
365+
const pkceParams = {
366+
code_challenge: params.codeChallenge,
367+
code_challenge_method: "S256",
368+
};
369+
370+
return {
371+
url: appendQueryString(url, pkceParams),
372+
codeVerifier: params.codeVerifier
373+
}
374+
}
375+
346376
/**
347377
* Verify the stored access token
348378
* @throws {MicropubError} If the token verification fails

0 commit comments

Comments
 (0)