Skip to content

Commit

Permalink
Add JWT handling
Browse files Browse the repository at this point in the history
  • Loading branch information
cultpodcasts committed May 1, 2024
1 parent d31624c commit d67dda3
Show file tree
Hide file tree
Showing 9 changed files with 431 additions and 0 deletions.
15 changes: 15 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseJwt } from './jwt/parse';

export interface Env {
Content: R2Bucket;
Data: R2Bucket;
Expand Down Expand Up @@ -40,6 +42,19 @@ export default {
});
}

const jwt = request.headers.get('Authorization');
if (jwt) {
const issuer = 'https://dev-q3x2z6aofdzbjkkf.us.auth0.com/';
const audience = 'https://api.cultpodcasts.com/';

const result = await parseJwt(jwt, issuer, audience);
if (!result.valid) {
console.log(result.reason); // Invalid issuer/audience, expired, etc
} else {
console.log(result.payload); // { iss, sub, aud, iat, exp, ...claims }
}
}

if (pathname.startsWith(homeRoute) && request.method === "GET") {
const object = await env.Content.get("homepage");

Expand Down
7 changes: 7 additions & 0 deletions src/jwt/algs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const algToHash: Record<string, string> = {
RS256: 'SHA-256',
RS384: 'SHA-384',
RS512: 'SHA-512'
};

export const algs = Object.keys(algToHash);
20 changes: 20 additions & 0 deletions src/jwt/decode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { base64url } from 'rfc4648';
import { DecodedJwt } from './types.js';

/**
* Decode a JWT into header, payload, and signature components.
*/
export function decodeJwt(token: string): DecodedJwt {
const [header, payload, signature] = token.split('.');
const decoder = new TextDecoder();
return {
header: JSON.parse(
decoder.decode(base64url.parse(header, { loose: true }))
),
payload: JSON.parse(
decoder.decode(base64url.parse(payload, { loose: true }))
),
signature: base64url.parse(signature, { loose: true }),
raw: { header, payload, signature }
};
}
21 changes: 21 additions & 0 deletions src/jwt/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { IssuerMetadata } from './types';

/**
* Fetch an oidc discovery document
*/
export async function getIssuerMetadata(
issuer: string
): Promise<IssuerMetadata> {
const url = new URL(issuer);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
url.pathname += '.well-known/openid-configuration';
const response = await fetch(url.href);
if (!response.ok) {
throw new Error(
`Error loading OpenID discovery document at ${url.href}. ${response.status} ${response.statusText}`
);
}
return response.json();
}
5 changes: 5 additions & 0 deletions src/jwt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { decodeJwt } from './decode.js';
export { getJwks, getKey, importKey } from './jwks.js';
export { parseJwt } from './parse.js';
export * from './types.js';
export { verifyJwtSignature } from './verify.js';
89 changes: 89 additions & 0 deletions src/jwt/jwks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { algToHash } from './algs.js';
import { getIssuerMetadata } from './discovery.js';
import { DecodedJwt, JsonWebKeyset } from './types.js';

/**
* Fetch a json web keyset.
*/
export async function getJwks(issuer: string): Promise<JsonWebKeyset> {
const issuerMetadata = await getIssuerMetadata(issuer);

let url;
if (issuerMetadata.jwks_uri) {
url = new URL(issuerMetadata.jwks_uri);
} else {
url = new URL(issuer);
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
url.pathname += '.well-known/jwks.json';
}

const response = await fetch(url.href);
if (!response.ok) {
throw new Error(
`Error loading jwks at ${url.href}. ${response.status} ${response.statusText}`
);
}
return response.json();
}

const importedKeys: Record<string, Record<string, CryptoKey>> = {};

/**
* Import and cache a JsonWebKeyset
* @param iss The issuer. Serves as the first-level cache key.
* @param jwks The JsonWebKeyset to import.
*/
export async function importKey(iss: string, jwk: JsonWebKey) {
if (jwk.kty !== 'RSA') {
throw new Error(
`Unsupported jwk key type (kty) "${
jwk.kty
}": Full JWK was ${JSON.stringify(jwk)}`
);
}
// alg is not mandatory in a JWK but is available in the JWT, for now we default to
// SHA-256 (RS256) because this is the most common but evaluating the key length
// of the JWKS is probably an ideal way of identifying what the alg is.
const hash = jwk.alg ? algToHash[jwk.alg] : 'SHA-256';
if (!hash) {
throw new Error(
`Unsupported jwk Algorithm (alg) "${
jwk.alg
}": Full JWK was ${JSON.stringify(jwk)}`
);
}
const key = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'RSASSA-PKCS1-v1_5', hash },
false,
['verify']
);
importedKeys[iss] = importedKeys[iss] || {};
importedKeys[iss][jwk.kid ?? 'default'] = key;
}

/**
* Get the CryptoKey associated with the JWT's issuer.
*/
export async function getKey(decoded: DecodedJwt): Promise<CryptoKey> {
const {
header: { kid = 'default' },
payload: { iss }
} = decoded;

if (!importedKeys[iss]) {
const jwks = await getJwks(iss);
await Promise.all(jwks.keys.map(jwk => importKey(iss, jwk)));
}

const key = importedKeys[iss][kid];

if (!key) {
throw new Error(`Error jwk not found. iss: ${iss}; kid: ${kid};`);
}

return key;
}
177 changes: 177 additions & 0 deletions src/jwt/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { algs, algToHash } from './algs.js';
import { decodeJwt } from './decode.js';
import { getKey } from './jwks.js';
import { DecodedJwt, JwtParseResult } from './types.js';
import { verifyJwtSignature } from './verify.js';

const skewMs = 30 * 1000;

/**
* Parse a JWT.
*/
export async function parseJwt(
encodedToken: string,
issuer: string | string[],
audience: string,
resolveKey: (decoded: DecodedJwt) => Promise<CryptoKey | null> = getKey
): Promise<JwtParseResult> {
let decoded: DecodedJwt;
try {
decoded = decodeJwt(encodedToken);
} catch {
return { valid: false, reason: `Unable to decode JWT.` };
}
const { typ, alg } = decoded.header;
if (typeof typ !== 'undefined' && typ !== 'JWT') {
return {
valid: false,
reason: `Invalid JWT type ${JSON.stringify(typ)}. Expected "JWT".`
};
}
if (!algToHash[alg]) {
return {
valid: false,
reason: `Invalid JWT algorithm ${JSON.stringify(
alg
)}. Supported: ${algs}.`
};
}

const { sub, aud, iss, iat, exp, nbf } = decoded.payload;
if (typeof sub !== 'string') {
return {
valid: false,
reason: `Subject claim (sub) is required and must be a string. Received ${JSON.stringify(
sub
)}.`
};
}

if (typeof aud === 'string') {
if (aud !== audience) {
return {
valid: false,
reason: `Invalid JWT audience claim (aud) ${JSON.stringify(
aud
)}. Expected "${audience}".`
};
}
} else if (
Array.isArray(aud) &&
aud.length > 0 &&
aud.every(a => typeof a === 'string')
) {
if (!aud.includes(audience)) {
return {
valid: false,
reason: `Invalid JWT audience claim array (aud) ${JSON.stringify(
aud
)}. Does not include "${audience}".`
};
}
} else {
return {
valid: false,
reason: `Invalid JWT audience claim (aud) ${JSON.stringify(
aud
)}. Expected a string or a non-empty array of strings.`
};
}

if (!(iss === issuer || (Array.isArray(issuer) && issuer.includes(iss)))) {
return {
valid: false,
reason: `Invalid JWT issuer claim (iss) ${JSON.stringify(
decoded.payload.iss
)}. Expected ${JSON.stringify(issuer)}.`
};
}

if (typeof exp !== 'number') {
return {
valid: false,
reason: `Invalid JWT expiry date claim (exp) ${JSON.stringify(
exp
)}. Expected number.`
};
}
const currentDate = new Date(Date.now());
const expiryDate = new Date(0);
expiryDate.setUTCSeconds(exp);
const expired = expiryDate.getTime() <= currentDate.getTime() - skewMs;
if (expired) {
return {
valid: false,
reason: `JWT is expired. Expiry date: ${expiryDate}; Current date: ${currentDate};`
};
}

if (nbf !== undefined) {
if (typeof nbf !== 'number') {
return {
valid: false,
reason: `Invalid JWT not before date claim (nbf) ${JSON.stringify(
nbf
)}. Expected number.`
};
}
const notBeforeDate = new Date(0);
notBeforeDate.setUTCSeconds(nbf);
const early = notBeforeDate.getTime() > currentDate.getTime() + skewMs;
if (early) {
return {
valid: false,
reason: `JWT cannot be used prior to not before date claim (nbf). Not before date: ${notBeforeDate}; Current date: ${currentDate};`
};
}
}

if (iat !== undefined) {
if (typeof iat !== 'number') {
return {
valid: false,
reason: `Invalid JWT issued at date claim (iat) ${JSON.stringify(
iat
)}. Expected number.`
};
}
const issuedAtDate = new Date(0);
issuedAtDate.setUTCSeconds(iat);
const postIssued = issuedAtDate.getTime() > currentDate.getTime() + skewMs;
if (postIssued) {
return {
valid: false,
reason: `JWT issued at date claim (iat) is in the future. Issued at date: ${issuedAtDate}; Current date: ${currentDate};`
};
}
}

let key: CryptoKey | null;
try {
key = await resolveKey(decoded);
} catch (e) {
return {
valid: false,
reason: `Error retrieving public key to verify JWT signature: ${
e instanceof Error ? e.message : e
}`
};
}
if (!key) {
return {
valid: false,
reason: `Unable to resolve public key to verify JWT signature.`
};
}
let signatureValid: boolean;
try {
signatureValid = await verifyJwtSignature(decoded, key);
} catch {
return { valid: false, reason: `Error verifying JWT signature.` };
}
if (!signatureValid) {
return { valid: false, reason: `JWT signature is invalid.` };
}
const payload = decoded.payload;
return { valid: true, payload };
}
Loading

0 comments on commit d67dda3

Please sign in to comment.