@oada/certs
Version:
Generate and verify JWT signatures (OAuth dynamic client registration certificates and Trellis document integrity signatures) in the Open Ag Data Alliance (OADA) and Trellis ecosystems
462 lines (410 loc) • 14.5 kB
text/typescript
/**
* @license
* Copyright 2014 Qlever LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import url from 'node:url';
import { JWK as jose_JWK, util } from 'node-jose';
import type { Entry } from 'type-fest';
import debug from 'debug';
import equal from 'deep-equal';
import request from 'superagent';
import type { RSA_JWK } from 'pem-jwk';
const trace = debug('oada-certs:jwks-utils:trace');
const info = debug('oada-certs:jwks-utils:info');
const warn = debug('oada-certs:jwks-utils:warn');
/**
* @todo Better discriminated union of JWK types?
*/
export type JWK = JWKpem | JWKrsa;
export interface BaseJWK extends Partial<jose_JWK.RawKey> {
/**
* Must have "kty" to be a JWK
*/
kty: string;
}
export interface JWKpem extends BaseJWK {
kty: 'PEM';
pem: string;
}
export interface JWKrsa extends BaseJWK, RSA_JWK {
kty: 'RSA';
n: string;
e: string;
}
export interface JWKs {
keys: readonly JWK[];
}
export interface JOSEHeader {
alg: 'RS256';
typ: string;
kid?: string;
jku?: string;
jwk?: JWK;
}
/**
* Cache jwks requests/responses:
*/
const jwksCache: Map<
string,
{ jwks: JWKs; timePutIntoCache: number; strbytes: number }
> = new Map();
const cacheStaleTimeoutSec = 3600; // 1 hour
/**
* How long to use cached value if network request fails
*/
const cacheFailureTimeout = 3600 * 24;
/**
* Maximum MB allowed in the jwks cache before pruning old ones
*/
const cacheMaxSizeMB = 20;
function cacheSize() {
return Array.from(jwksCache.keys()).reduce(
(accumulator, uri) => accumulator + jwksCache.get(uri)!.strbytes,
0
);
}
export function cachePruneOldest() {
let [olduri, oldest]: Entry<typeof jwksCache> = [
'',
{
strbytes: 0,
timePutIntoCache: Number.POSITIVE_INFINITY,
jwks: { keys: [] },
},
];
for (const [uri, jwks] of jwksCache) {
if (jwks.timePutIntoCache < oldest.timePutIntoCache) {
[olduri, oldest] = [uri, jwks];
}
}
return jwksCache.delete(olduri);
}
function putInCache(uri: string, jwks: JWKs, strbytes: number) {
if (strbytes / 1_000_000 > cacheMaxSizeMB) {
warn(
'Refusing to cache jwks from uri %s because its size alone (%d) is larger than cacheMaxSizeMB (%d)',
uri,
strbytes,
cacheMaxSizeMB
);
return false;
}
while (cacheSize() + strbytes > cacheMaxSizeMB) {
if (!cachePruneOldest()) {
break; // If pruneOldest fails, stop looping
}
}
if (jwksCache.has(uri)) {
trace(
'Putting uri %s into cache with new timestamp, replacing previous entry',
uri
);
}
jwksCache.set(uri, {
strbytes,
timePutIntoCache: Date.now() / 1000,
jwks,
});
return true;
}
const cachePruneIfFailureTimeout = (uri: string) => {
const now = Date.now() / 1000;
if (
jwksCache.has(uri) &&
now - jwksCache.get(uri)!.timePutIntoCache > cacheFailureTimeout
) {
info(
'jku request failed for uri %s, and it has been longer than cacheFailureTimeout, so removing that uri from cache due to failure',
uri
);
// Remove from cache
jwksCache.delete(uri);
}
};
const cacheHasURIThatIsNotStale = (uri: string) => {
const now = Date.now() / 1000;
return (
jwksCache.has(uri) &&
now - jwksCache.get(uri)!.timePutIntoCache < cacheStaleTimeoutSec
);
};
// -------------------------------------------------
// Primary exported module:
// Exporting some cache functions for testing:
export function clearJWKsCache() {
jwksCache.clear();
}
export function getJWKsCache() {
return jwksCache;
}
/**
* Decide if an object is a JWK
*/
export function isJWK(key: unknown): key is JWK {
return typeof key === 'object' && Boolean(key) && 'kty' in key!;
}
/**
* Decide if an object is a set of JWKs
*/
export function isJWKset(set: unknown): set is JWKs {
// @ts-expect-error stuff
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const keys = set?.keys;
return Array.isArray(keys) && keys.some((element) => isJWK(element));
}
/**
* Pick a JWK from a JWK set by its Key ID
*/
export function findJWK(kid: string | undefined, jwks: JWKs) {
if (!kid) {
return;
}
const result = jwks.keys.find((jwk) => isJWK(jwk) && jwk.kid === kid);
trace(result, 'findJWK: returning ');
return result;
}
async function fetchAndCacheJKU(
uri: string,
timeout: number
): Promise<JWKs | undefined> {
const jwkRequest = request.get(uri);
if (typeof jwkRequest.buffer === 'function') {
void jwkRequest.buffer();
}
void jwkRequest.timeout(timeout);
const resp = await jwkRequest.send();
// If there was no error, then we can go ahead and try to parse the body (which could result in an error)
trace(
'Finished retrieving uri %s, had no error in the request, will now try to parse response.',
uri
);
const jwks: unknown = JSON.parse(resp.text);
if (!isJWKset(jwks)) {
throw new Error(
'jwks parsed successfully with JSON.parse, but it was not a valid jwks'
);
}
// Put this successful jwks set into the cache
if (putInCache(uri, jwks, resp.text.length)) {
trace('Added jwks to cache for uri %s', uri);
return jwks;
}
info('Failed to add jwks to cache for uri %', uri);
return undefined;
}
export function decodeWithoutVerify(jwt: string) {
const [sheader, spayload, signature] = jwt.split('.');
trace('decodeWithoutVerify: parts before decoding = %s', [
sheader,
spayload,
signature,
]);
let header: JOSEHeader;
let payload: unknown;
try {
header = JSON.parse(
util.base64url.decode(sheader!).toString()
) as JOSEHeader;
} catch (error: unknown) {
throw new Error(
`Could not JSON.parse decoded header. Header string is: ${sheader}, error was: ${error}`
);
}
try {
payload = JSON.parse(util.base64url.decode(spayload!).toString());
} catch (error: unknown) {
warn(
'Could not JSON.parse payload, assuming it is a string to be left alone. Payload string is: %s, error was: %o',
spayload,
error
);
payload = spayload;
}
trace(
'decodeWithoutVerify: decoded header = %o, payload = %o, signature = %s',
header,
payload,
signature
);
return { header, payload, signature };
}
/**
* Supported headers: [kid, jwk, jku]
*/
export async function jwkForSignature(
sig: string,
hint: false | string | JWKs | JWK,
{ timeout = 1000 }: { timeout?: number } = {}
) {
const { header } = decodeWithoutVerify(sig);
const checkJWKEqualsJoseJWK = (jwk?: JWK) => {
trace(jwk, 'checkJWKEqualsJoseJWK: started function');
if (!jwk) {
warn(
'jwk is falsey: there was no final JWK to check against the JOSE header. Did you use a jku on an untrusted signature?'
);
throw new Error(
'There was no final JWK to check against the JOSE header. Did you use a jku on an untrusted signature?'
);
}
if (header.jwk && !equal(jwk, header.jwk, { strict: true })) {
warn('header.jwk (%o) did not match jwk (%o)', header.jwk, jwk);
throw new Error('JWK did not match jwk JOSE header');
}
trace(jwk, 'checkJWKEqualsJoseJWK: successful');
return jwk;
};
// Retrieve JWKS from a JKU URI
// Update for 1.0.6: Adding an in-memory cache that will both
// 1: speedup requests with jku's, and
// 2: keep working with cached copy for 24 hours in the event of network outage.
// The cache will respond immediately with a cached value if the cached value is less than
// 1 hour old and the kid is in it. It will always go get the latest jwks at the URL to
// update the cache, but if the kid is already in the cached copy it will return that immediately
// and not wait on the request to finish. If the kid is not found in the cached value, it will
// wait for the request to finish and then look for the kid in the new copy. In this way, if a
// new kid is published in your jwks, it will be immediately available to all clients. If a key
// is deemed no longer trusted and removed from the jwks, then it will only validate at most one
// time in at most a 1 hour window after un-publishing.
async function getJWK(uri: string): Promise<JWK> {
// MUST use HTTPS (not HTTP)
const u = new URL(uri);
u.protocol = 'https';
uri = url.format(u);
const resp = fetchAndCacheJKU(uri, timeout);
// Fire off the request here first, then immediately check cache before javascript event queue moves on.
// If it's there, then that "thread" of execution will call the callback instead of the one after
// the request finishes. If it wasn't there, then the request's callback here will call it.
trace(
'Sending out GET request for uri %s, will check cache while it is waiting',
uri
);
let jwks: JWKs;
try {
// Now, check if we already have the uri in the cache and
// if the kid is already in it's list of keys:
trace('Checking cache for non-stale uri ', uri);
if (cacheHasURIThatIsNotStale(uri)) {
trace(
'Found uri %s in cache and it is not stale, returning it immediately',
uri
);
const jwk = findJWK(header.kid, jwksCache.get(uri)!.jwks);
if (jwk) {
return checkJWKEqualsJoseJWK(jwk);
}
}
// If we get here, then we did not have a valid, un-stale kid in the cache, so we need
// to wait on the request to call the callback instead (above). If it fails above, then it
// will continue to use the stale URI until the 24-hour failure period. The callback for the
// overall function will end up being called in the callback for the request.
trace(
'Did not find non-stale uri %s in cache, waiting on request to complete instead',
uri
);
const result = await resp;
if (!result) {
throw new Error(
`Failed to get jwks from uri ${uri}, and it was not in the cache`
);
}
jwks = result;
} catch (error: unknown) {
// If we get to this point, either jwks is valid and in the jwks variable, or we had an error
warn('jku request failed for uri %s', uri);
// If the request had an error (i.e. network or host is down now), let's check if we have
// the jwks in the cache. If we do, we'll go ahead and use it for up to 24 hours before
// removing it due to the failure.
cachePruneIfFailureTimeout(uri);
// Now if it's not in the cache, since the request had an error, then return the error
if (!jwksCache.has(uri)) {
warn('uri (%s) had error, and it is not in cache, throwing', uri);
throw error;
}
// If we get here, there was an error, but it was in the cache still before the cacheFailureTimeout,
// so put that in the main jwks variable to check later
info(
'jku request failed for uri %s, but we have cached copy that we will use for 24 hours',
uri
);
jwks = jwksCache.get(uri)!.jwks;
}
// And finally, if we got to this point, we either did not have an error, or we had an error but
// we decided to use our cached value. Either way, the jwks variable now has a valid jwks in it.
// This ends the thread that runs after the web request finishes.
trace(
'Finished with request path, looking for header.kid (%s) in retrieved jwks:%o',
header.kid,
jwks
);
return checkJWKEqualsJoseJWK(findJWK(header.kid, jwks));
}
// Now we can do the main part of the function which checks the hint and then calls one of
// the functions above....
// This hint thing is complicated....
// It was designed to make it simple to say something like:
// "hint: I looked up the JKU ot JWK on the signature, and it was from a trusted source."
// (i.e. hint = truthy),
// and it's truthy value is then either the JWKS (object) from the trusted source,
// or the jku (string) of the trusted source's jwks
// or
// "hint: I looked at my trusted sources and this one doesn't have a jwk or jku that matches."
// (i.e. hint === false)
// which means "just use the header on the signature because I have no outside reference that verifies it"
//
// - If boolean false, use the jku from the jose header and if no jku then use jose's jwk
// - If boolean true, throw error (it should have been either an object or a string)
// - If string, assume string is a jku uri, go get that URI and then check jose's jwk against it
// - If object and looks like a jwks, look for jose's jwk in the set
// - If object and looks like a jwk, compare that jwk with jose's jwk
switch (typeof hint) {
case 'boolean':
if (!hint) {
// Lookup solely based on JOSE headers
if (header.jku) {
warn(
'signature has a jku key, but it is untrusted and therefore ignored to avoid getting potentially malicious URIs.'
);
}
if (!header.jwk) {
warn('signature is untrusted and has no jwk key to check');
return checkJWKEqualsJoseJWK();
}
trace(
'hint is boolean false, but we do have a jwk in the header so we will check that.'
);
// If no jku uri, then just use the jwk on the jose header as last resort
return checkJWKEqualsJoseJWK(header.jwk);
}
break;
case 'string':
trace('hint is a string, assuming URL to getJWK');
return getJWK(hint);
case 'object':
if (isJWKset(hint)) {
trace('hint is object, looks like jwk set, checking that');
return checkJWKEqualsJoseJWK(findJWK(header.kid, hint));
}
if (isJWK(hint) && header.kid === hint.kid) {
trace('hint is object, looks like jwk w/ same kid, checking');
return checkJWKEqualsJoseJWK(hint);
}
break;
default:
}
// If we get here, the hint didn't make sense so we error out:
warn('jwkForSignature: Hint was invalid!');
throw new Error('Invalid hint');
}