Skip to content

Commit b5c7b11

Browse files
committed
added more tests
1 parent e4411aa commit b5c7b11

File tree

9 files changed

+193
-106
lines changed

9 files changed

+193
-106
lines changed

src/base64.test.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/base64.ts

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,23 @@
1-
export const decodeBase64Url = (str: string): string => {
2-
return decodeBase64(str).replace(/_|-/g, (m) => ({ _: "/", "-": "+" }[m]!));;
1+
export const decodeBase64Url = (str: string): Uint8Array => {
2+
return decodeBase64(str.replace(/_|-/g, (m) => ({ _: "/", "-": "+" }[m]!)))
33
};
44

5-
export const decodeBase64UrlBytes = (str: string): Uint8Array =>
6-
new TextEncoder().encode(decodeBase64Url(str));
7-
8-
export const encodeBase64UrlBytes = (buf: ArrayBufferLike) => {
9-
return encodeBase64Url(String.fromCharCode(...new Uint8Array(buf)));
10-
};
11-
12-
export const encodeBase64Url = (str: string): string =>
13-
encodeBase64(str).replace(/\/|\+/g, (m) => ({ "/": "_", "+": "-" }[m]!));
14-
15-
const pad = (s: string): string => {
16-
switch (s.length % 4) {
17-
case 2:
18-
return `${s}==`;
19-
case 3:
20-
return `${s}=`;
21-
default:
22-
return s;
23-
}
24-
};
5+
export const encodeBase64Url = (buf: ArrayBufferLike): string =>
6+
encodeBase64(buf).replace(/\/|\+/g, (m) => ({ "/": "_", "+": "-" }[m]!));
257

268
// This approach is written in MDN.
279
// btoa does not support utf-8 characters. So we need a little bit hack.
28-
const encodeBase64 = (str: string): string => {
29-
const binary = []
30-
const encoded = new TextEncoder().encode(str)
31-
for (let i = 0; i < encoded.byteLength; i++) {
32-
binary.push(String.fromCharCode(encoded[i]))
33-
}
34-
return pad(btoa(binary.join('')))
10+
export const encodeBase64 = (buf: ArrayBufferLike): string => {
11+
const binary = String.fromCharCode(...new Uint8Array(buf))
12+
return btoa(binary)
3513
}
3614

3715
// atob does not support utf-8 characters. So we need a little bit hack.
38-
const decodeBase64 = (str: string): string => {
39-
const binary = atob(pad(str))
16+
const decodeBase64 = (str: string): Uint8Array => {
17+
const binary = atob(str)
4018
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
41-
for (let i = 0; i < binary.length; i++) {
19+
for (let i = 0; i < binary.length ; i++) {
4220
bytes[i] = binary.charCodeAt(i);
4321
}
44-
return new TextDecoder().decode(bytes)
22+
return bytes
4523
}

src/jws-verifier.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { JsonWebKeyWithKid, RS256Token } from "./jwt-decoder";
44
import { isNonNullObject } from "./validator";
55

66
// https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library
7-
const rs256alg = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
7+
// https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams
8+
export const rs256alg: RsaHashedKeyGenParams = {
9+
name: "RSASSA-PKCS1-v1_5",
10+
modulusLength: 2048,
11+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
12+
hash: "SHA-256",
13+
};
814

915
export interface SignatureVerifier {
1016
verify(token: RS256Token): Promise<void>;
@@ -44,7 +50,8 @@ export class PublicKeySignatureVerifier implements SignatureVerifier {
4450
if (publicKey.kid !== header.kid) {
4551
continue;
4652
}
47-
if (await this.verifySignature(token, publicKey)) {
53+
const verified = await this.verifySignature(token, publicKey)
54+
if (verified) {
4855
// succeeded
4956
return;
5057
}

src/jwt-decoder.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { decodeBase64UrlBytes } from "./base64";
1+
import { decodeBase64Url } from "./base64";
22
import { JwtError, JwtErrorCode } from "./errors";
3+
import { utf8Decoder, utf8Encoder } from "./utf8";
34
import { isNonEmptyString, isNumber, isString } from "./validator";
45

56
export interface TokenDecoder {
@@ -10,7 +11,7 @@ export interface JsonWebKeyWithKid extends JsonWebKey {
1011
kid: string;
1112
}
1213

13-
type DecodedHeader = { kid: string; alg: "RS256" } & Record<string, any>;
14+
export type DecodedHeader = { kid: string; alg: "RS256" } & Record<string, any>;
1415

1516
export type DecodedPayload = {
1617
aud: string;
@@ -52,7 +53,7 @@ export class RS256Token {
5253
return new RS256Token(token, {
5354
header,
5455
payload,
55-
signature: decodeBase64UrlBytes(tokenParts[2]),
56+
signature: decodeBase64Url(tokenParts[2]),
5657
});
5758
}
5859

@@ -61,7 +62,7 @@ export class RS256Token {
6162

6263
// `${token.header}.${token.payload}`
6364
const trimmedSignature = rawToken.substring(0, rawToken.lastIndexOf("."));
64-
return new TextEncoder().encode(trimmedSignature);
65+
return utf8Encoder.encode(trimmedSignature);
6566
}
6667
}
6768

@@ -93,42 +94,42 @@ const decodePayload = (
9394
if (!isNonEmptyString(payload.aud)) {
9495
throw new JwtError(
9596
JwtErrorCode.INVALID_ARGUMENT,
96-
`"aud" claim must be a string but got ${payload.aud}}`
97+
`"aud" claim must be a string but got "${payload.aud}"`
9798
);
9899
}
99100

100101
if (!isNonEmptyString(payload.sub)) {
101102
throw new JwtError(
102103
JwtErrorCode.INVALID_ARGUMENT,
103-
`"sub" claim must be a string but got ${payload.sub}}`
104+
`"sub" claim must be a string but got "${payload.sub}}"`
104105
);
105106
}
106107

107108
if (!isNonEmptyString(payload.iss)) {
108109
throw new JwtError(
109110
JwtErrorCode.INVALID_ARGUMENT,
110-
`"iss" claim must be a string but got ${payload.iss}}`
111+
`"iss" claim must be a string but got "${payload.iss}}"`
111112
);
112113
}
113114

114115
if (!isNumber(payload.iat)) {
115116
throw new JwtError(
116117
JwtErrorCode.INVALID_ARGUMENT,
117-
`"iat" claim must be a number but got ${payload.iat}}`
118+
`"iat" claim must be a number but got "${payload.iat}}"`
118119
);
119120
}
120121

121122
if (currentTimestamp < payload.iat) {
122123
throw new JwtError(
123124
JwtErrorCode.INVALID_ARGUMENT,
124-
`Incorrect "iat" claim must be a newer than "${currentTimestamp}" (iat: ${payload.iat})`
125+
`Incorrect "iat" claim must be a newer than "${currentTimestamp}" (iat: "${payload.iat}")`
125126
);
126127
}
127128

128129
if (!isNumber(payload.exp)) {
129130
throw new JwtError(
130131
JwtErrorCode.INVALID_ARGUMENT,
131-
`"exp" claim must be a number but got ${payload.exp}}`
132+
`"exp" claim must be a number but got "${payload.exp}"}`
132133
);
133134
}
134135

@@ -143,21 +144,9 @@ const decodePayload = (
143144
};
144145

145146
const decodeBase64JSON = (b64Url: string): any => {
146-
let b64 = b64Url.replace(/-/g, "+").replace(/_/g, "/");
147-
switch (b64.length % 4) {
148-
case 0:
149-
break;
150-
case 2:
151-
b64 += "==";
152-
break;
153-
case 3:
154-
b64 += "=";
155-
break;
156-
default:
157-
throw new Error("Illegal base64url string.");
158-
}
147+
const decoded = decodeBase64Url(b64Url)
159148
try {
160-
return JSON.parse(decodeURIComponent(atob(b64)));
149+
return JSON.parse(utf8Decoder.decode(decoded));
161150
} catch {
162151
return null;
163152
}

src/utf8.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const utf8Encoder = new TextEncoder()
2+
export const utf8Decoder = new TextDecoder()

tests/base64.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { decodeBase64Url, encodeBase64Url } from "../src/base64";
2+
import { utf8Encoder } from "../src/utf8";
3+
4+
const urlRef = (s: string): string =>
5+
s.replace(/\+|\//g, (m) => ({ "+": "-", "/": "_" }[m]!))
6+
7+
const str2UInt8Array = (s: string): Uint8Array => {
8+
const buffer = new Uint8Array(new ArrayBuffer(s.length));
9+
for (let i = 0; i < buffer.byteLength; i++) {
10+
buffer[i] = s.charCodeAt(i);
11+
}
12+
return buffer
13+
}
14+
15+
describe("base64", () => {
16+
describe.each([
17+
// basic
18+
[utf8Encoder.encode("Hello, 世界"), "SGVsbG8sIOS4lueVjA=="],
19+
20+
// RFC 3548 examples
21+
[str2UInt8Array("\x14\xfb\x9c\x03\xd9\x7e"), "FPucA9l+"],
22+
[str2UInt8Array("\x14\xfb\x9c\x03\xd9"), "FPucA9k="],
23+
[str2UInt8Array("\x14\xfb\x9c\x03"), "FPucAw=="],
24+
25+
// RFC 4648 examples
26+
[str2UInt8Array(""), ""],
27+
[str2UInt8Array("f"), "Zg=="],
28+
[str2UInt8Array("fo"), "Zm8="],
29+
[str2UInt8Array("foo"), "Zm9v"],
30+
[str2UInt8Array("foob"), "Zm9vYg=="],
31+
[str2UInt8Array("fooba"), "Zm9vYmE="],
32+
[str2UInt8Array("foobar"), "Zm9vYmFy"],
33+
34+
// Wikipedia examples
35+
[str2UInt8Array("sure."), "c3VyZS4="],
36+
[str2UInt8Array("sure"), "c3VyZQ=="],
37+
[str2UInt8Array("sur"), "c3Vy"],
38+
[str2UInt8Array("su"), "c3U="],
39+
[str2UInt8Array("leasure."), "bGVhc3VyZS4="],
40+
[str2UInt8Array("easure."), "ZWFzdXJlLg=="],
41+
[str2UInt8Array("asure."), "YXN1cmUu"],
42+
[str2UInt8Array("sure."), "c3VyZS4="],
43+
])('%s, %s', (decoded, encoded) => {
44+
it("encode", () => {
45+
const got = encodeBase64Url(decoded)
46+
const want = urlRef(encoded)
47+
expect(got).toStrictEqual(want)
48+
})
49+
it("decode", () => {
50+
const got = decodeBase64Url(urlRef(encoded))
51+
const want = decoded
52+
expect(got).toStrictEqual(want)
53+
})
54+
})
55+
})

tests/jwk-utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { encodeBase64Url } from "../src/base64";
2+
import { KeyFetcher } from "../src/jwk-fetcher";
3+
import { rs256alg } from "../src/jws-verifier";
4+
import { DecodedHeader, DecodedPayload, JsonWebKeyWithKid } from "../src/jwt-decoder";
5+
import { utf8Encoder } from "../src/utf8";
6+
7+
export class TestingKeyFetcher implements KeyFetcher {
8+
9+
constructor(public readonly kid: string, private readonly keyPair: CryptoKeyPair) {}
10+
11+
public static async withKeyPairGeneration(kid: string): Promise<TestingKeyFetcher> {
12+
const keyPair = await crypto.subtle.generateKey(rs256alg, true, ["sign", "verify"])
13+
return new TestingKeyFetcher(kid, keyPair)
14+
}
15+
16+
public async fetchPublicKeys(): Promise<Array<JsonWebKeyWithKid>> {
17+
const publicJWK = await crypto.subtle.exportKey("jwk", this.keyPair.publicKey)
18+
return [{kid: this.kid, ...publicJWK}];
19+
}
20+
21+
public getPrivateKey(): CryptoKey {
22+
return this.keyPair.privateKey
23+
}
24+
}
25+
26+
export const genIat = (ms: number = Date.now()): number => Math.floor(ms / 1000)
27+
export const genIss = (projectId: string = "projectId1234"): string => "https://securetoken.google.com/" + projectId
28+
29+
const jsonUTF8Stringify = (obj: any): Uint8Array => utf8Encoder.encode(JSON.stringify(obj))
30+
31+
export const signJWT = async (kid: string, payload: DecodedPayload, privateKey: CryptoKey) => {
32+
const header: DecodedHeader = {
33+
alg: 'RS256',
34+
typ: 'JWT',
35+
kid,
36+
};
37+
const encodedHeader = encodeBase64Url(jsonUTF8Stringify(header)).replace(/=/g, "")
38+
const encodedPayload = encodeBase64Url(jsonUTF8Stringify(payload)).replace(/=/g, "")
39+
const headerAndPayload = `${encodedHeader}.${encodedPayload}`;
40+
41+
const signature = await crypto.subtle.sign(
42+
rs256alg,
43+
privateKey,
44+
utf8Encoder.encode(headerAndPayload),
45+
);
46+
47+
const base64Signature = encodeBase64Url(signature).replace(/=/g, "")
48+
return `${headerAndPayload}.${base64Signature}`;
49+
}

0 commit comments

Comments
 (0)