Skip to content

Commit 0ef3325

Browse files
committed
added jwk-fetcher test
1 parent acd2d5e commit 0ef3325

File tree

9 files changed

+269
-78
lines changed

9 files changed

+269
-78
lines changed

jest.config.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,23 @@ const { resolve } = require("path");
22
const { pathsToModuleNameMapper } = require("ts-jest");
33

44
const pkg = require("./package.json");
5-
const tsconfig = require("./tsconfig.json");
5+
const tsconfig = require("./tests/tsconfig.json");
66
const CI = !!process.env.CI;
77

88
module.exports = () => {
99
return {
10+
preset: "ts-jest/presets/default-esm",
11+
globals: {
12+
"ts-jest": {
13+
tsconfig: "./tests/tsconfig.json",
14+
},
15+
},
1016
displayName: pkg.name,
1117
rootDir: __dirname,
12-
preset: "ts-jest",
1318
testEnvironment: "miniflare",
19+
testEnvironmentOptions: {
20+
kvNamespaces: ["TEST_NAMESPACE"],
21+
},
1422
restoreMocks: true,
1523
reporters: ["default"],
1624
modulePathIgnorePatterns: ["dist"],

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77
"scripts": {
88
"test": "jest"
99
},
10-
"dependencies": {
11-
"@cloudflare/workers-types": "^3.13.0"
12-
},
10+
"dependencies": {},
1311
"devDependencies": {
12+
"@cloudflare/workers-types": "^3.14.0",
1413
"@types/jest": "^28.1.3",
1514
"jest": "^28.1.2",
1615
"jest-environment-miniflare": "^2.5.1",

src/jwk-fetcher.ts

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,15 @@ interface JWKMetadata {
1212
const isJWKMetadata = (value: any): value is JWKMetadata =>
1313
isNonNullObject(value) && !!value.keys && isArray(value.keys);
1414

15-
// {
16-
// "keys": [
17-
// {
18-
// "kid": "f90fb1ae048a548fb681ad6092b0b869ea467ac6",
19-
// "e": "AQAB",
20-
// "kty": "RSA",
21-
// "n": "v1DLA89xpRpQ2bA2Ku__34z98eISnT1coBgA3QNjitmpM-4rf1pPNH6MKxOOj4ZxvzSeGlOjB7XiQwX3lQJ-ZDeSvS45fWIKrDW33AyFn-Z4VFJLVRb7j4sqLa6xsTj5rkbJBDwwGbGXOo37o5Ewfn0S52GFDjl2ALKexIgu7cUKKHsykr_h6D6RdhwpHvjG_H5Omq9mY7wDxLTvtYyrpN3wONAf4uMsJn9GDgMsAu7UkhDSICX5jmhVUDvYJA3FKokFyjG7PdetNnh00prL_CtH1Bs8f06sWwQKQMTDUrKEyEHuc2bzWNfGXRrc-c_gRNWP9k7vzOTcAIFSWlA7Fw",
22-
// "alg": "RS256",
23-
// "use": "sig"
24-
// },
25-
// {
26-
// "use": "sig",
27-
// "kid": "9897cf9459e254ff1c67a4eb6efea52f21a9ba14",
28-
// "n": "ylSiwcLD0KXrnzo4QlVFdVjx3OL5x0qYOgkcdLgiBxABUq9Y7AuIwABlCKVYcMCscUnooQvEATShnLdbqu0lLOaTiK1JxblIGonZrOB8-MlXn7-RnEmQNuMbvNK7QdwTrz3uzbqB64Z70DoC0qLVPT5v9ivzNfulh6UEuNVvFupC2zbrP84oxzRmpgcF0lxpiZf4qfCC2aKU8wDCqP14-PqHLI54nfm9QBLJLz4uS00OqdwWITSjX3nlBVcDqvCbJi3_V-eoBP42prVTreILWHw0SqP6FGt2lFPWeMnGinlRLAdwaEStrPzclvAupR5vEs3-m0UCOUt0rZOZBtTNkw",
29-
// "e": "AQAB",
30-
// "kty": "RSA",
31-
// "alg": "RS256"
32-
// }
33-
// ]
34-
// }
35-
3615
/**
3716
* Class to fetch public keys from a client certificates URL.
3817
*/
3918
export class UrlKeyFetcher implements KeyFetcher {
40-
private readonly PUBLIC_KEY_CACHE_KEY = "google-public-jwks";
41-
4219
constructor(
43-
private readonly clientCertUrl: string,
20+
private readonly fetcher: Fetcher,
21+
private readonly cacheKey: string,
4422
private readonly cfKVNamespace: KVNamespace
45-
) {
46-
if (!isURL(clientCertUrl)) {
47-
throw new Error(
48-
"The provided public client certificate URL is not a valid URL."
49-
);
50-
}
51-
}
23+
) {}
5224

5325
/**
5426
* Fetches the public keys for the Google certs.
@@ -57,7 +29,7 @@ export class UrlKeyFetcher implements KeyFetcher {
5729
*/
5830
public async fetchPublicKeys(): Promise<Array<JsonWebKeyWithKid>> {
5931
const publicKeys = await this.cfKVNamespace.get<Array<JsonWebKeyWithKid>>(
60-
this.PUBLIC_KEY_CACHE_KEY,
32+
this.cacheKey,
6133
"json"
6234
);
6335
if (publicKeys === null || typeof publicKeys !== "object") {
@@ -67,8 +39,7 @@ export class UrlKeyFetcher implements KeyFetcher {
6739
}
6840

6941
private async refresh(): Promise<Array<JsonWebKeyWithKid>> {
70-
// TODO(codehex): add retry
71-
const resp = await fetch(this.clientCertUrl);
42+
const resp = await this.fetcher.fetch();
7243
if (!resp.ok) {
7344
let errorMessage = "Error fetching public keys for Google certs: ";
7445
const text = await resp.text();
@@ -78,48 +49,61 @@ export class UrlKeyFetcher implements KeyFetcher {
7849
const publicKeys = await resp.json();
7950
if (!isJWKMetadata(publicKeys)) {
8051
throw new Error(
81-
`The public keys are not an object or null: '${publicKeys}'`
52+
`The public keys are not an object or null: "${publicKeys}`
8253
);
8354
}
8455

8556
const cacheControlHeader = resp.headers.get("cache-control");
8657

8758
// store the public keys cache in the KV store.
88-
if (cacheControlHeader !== null) {
89-
const parts = cacheControlHeader.split(",");
90-
for (const part of parts) {
91-
const subParts = part.trim().split("=");
92-
if (subParts[0] !== "max-age") {
93-
continue;
59+
const maxAge = parseMaxAge(cacheControlHeader)
60+
if (!isNaN(maxAge)) {
61+
this.cfKVNamespace.put(
62+
this.cacheKey,
63+
JSON.stringify(publicKeys.keys),
64+
{
65+
expirationTtl: maxAge,
9466
}
95-
const maxAge: number = +subParts[1]; // maxAge is a seconds value.
96-
this.cfKVNamespace.put(
97-
this.PUBLIC_KEY_CACHE_KEY,
98-
JSON.stringify(publicKeys.keys),
99-
{
100-
expirationTtl: maxAge,
101-
}
102-
);
103-
}
67+
);
10468
}
10569

10670
return publicKeys.keys;
10771
}
10872
}
10973

110-
// This is an example of a response header that fetches public keys from a "clientCertUrl".
111-
// HTTP/2 200
112-
// < vary: X-Origin
113-
// < vary: Referer
114-
// < vary: Origin,Accept-Encoding
115-
// < server: scaffolding on HTTPServer2
116-
// < x-xss-protection: 0
117-
// < x-frame-options: SAMEORIGIN
118-
// < x-content-type-options: nosniff
119-
// < date: Sun, 26 Jun 2022 03:33:09 GMT
120-
// < expires: Sun, 26 Jun 2022 08:44:20 GMT
121-
// < cache-control: public, max-age=18671, must-revalidate, no-transform
122-
// < content-type: application/json; charset=UTF-8
123-
// < age: 32
124-
// < alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43"
125-
// < accept-ranges: none
74+
// parseMaxAge parses Cache-Control header and returns max-age value as number.
75+
// returns NaN when Cache-Control header is none or max-age is not found, the value is invalid.
76+
export const parseMaxAge = (cacheControlHeader: string | null): number => {
77+
if (cacheControlHeader === null) {
78+
return NaN
79+
}
80+
const parts = cacheControlHeader.split(",");
81+
for (const part of parts) {
82+
const subParts = part.trim().split("=");
83+
if (subParts[0] !== "max-age") {
84+
continue;
85+
}
86+
return Number(subParts[1]); // maxAge is a seconds value.
87+
}
88+
return NaN
89+
}
90+
91+
export interface Fetcher {
92+
fetch(): Promise<Response>
93+
}
94+
95+
export class HTTPFetcher implements Fetcher {
96+
constructor(
97+
private readonly clientCertUrl: string,
98+
) {
99+
if (!isURL(clientCertUrl)) {
100+
throw new Error(
101+
"The provided public client certificate URL is not a valid URL."
102+
);
103+
}
104+
}
105+
106+
public fetch(): Promise<Response> {
107+
return fetch(this.clientCertUrl)
108+
}
109+
}

src/jws-verifier.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { JwtError, JwtErrorCode } from "./errors";
2-
import { KeyFetcher, UrlKeyFetcher } from "./jwk-fetcher";
2+
import { HTTPFetcher, KeyFetcher, UrlKeyFetcher } from "./jwk-fetcher";
33
import { JsonWebKeyWithKid, RS256Token } from "./jwt-decoder";
44
import { isNonNullObject } from "./validator";
55

@@ -30,8 +30,9 @@ export class PublicKeySignatureVerifier implements SignatureVerifier {
3030
clientCertUrl: string,
3131
cfKVNamespace: KVNamespace
3232
): PublicKeySignatureVerifier {
33+
const fetcher = new HTTPFetcher(clientCertUrl)
3334
return new PublicKeySignatureVerifier(
34-
new UrlKeyFetcher(clientCertUrl, cfKVNamespace)
35+
new UrlKeyFetcher(fetcher, cfKVNamespace)
3536
);
3637
}
3738

tests/global.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface Bindings {
2+
TEST_NAMESPACE: KVNamespace;
3+
}
4+
5+
declare global {
6+
function getMiniflareBindings(): Bindings;
7+
}

0 commit comments

Comments
 (0)