Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache max age #98

Merged
merged 3 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions packages/access-token-jwt/src/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { URL } from 'url';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import fetch from './fetch';
import { strict as assert } from 'assert';
import { JwtVerifierOptions } from './jwt-verifier';

const OIDC_DISCOVERY = '/.well-known/openid-configuration';
const OAUTH2_DISCOVERY = '/.well-known/oauth-authorization-server';
Expand All @@ -17,15 +16,16 @@ export interface IssuerMetadata {
const assertIssuer = (data: IssuerMetadata) =>
assert(data.issuer, `'issuer' not found in authorization server metadata`);

export interface DiscoverOptions {
agent?: HttpAgent | HttpsAgent;
timeoutDuration?: number;
}
export type DiscoverOptions = Required<
Pick<JwtVerifierOptions, 'issuerBaseURL' | 'timeoutDuration' | 'cacheMaxAge'>
> &
Pick<JwtVerifierOptions, 'agent'>;

const discover = async (
uri: string,
{ agent, timeoutDuration }: DiscoverOptions = {}
): Promise<IssuerMetadata> => {
const discover = async ({
issuerBaseURL: uri,
agent,
timeoutDuration,
}: DiscoverOptions): Promise<IssuerMetadata> => {
const url = new URL(uri);

if (url.pathname.includes('/.well-known/')) {
Expand Down Expand Up @@ -63,4 +63,20 @@ const discover = async (
throw new Error('Failed to fetch authorization server metadata');
};

export default discover;
export default (opts: DiscoverOptions) => {
let discovery: Promise<IssuerMetadata> | undefined;
let timestamp = 0;

return () => {
const now = Date.now();

if (!discovery || now > timestamp + opts.cacheMaxAge) {
timestamp = now;
discovery = discover(opts).catch((e) => {
discovery = undefined;
throw e;
});
}
return discovery;
};
};
38 changes: 38 additions & 0 deletions packages/access-token-jwt/src/get-key-fn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createSecretKey } from 'crypto';
import { createRemoteJWKSet } from 'jose';
import { JwtVerifierOptions } from './jwt-verifier';

type GetKeyFn = ReturnType<typeof createRemoteJWKSet>;

export type JWKSOptions = Required<
Pick<
JwtVerifierOptions,
'cooldownDuration' | 'timeoutDuration' | 'cacheMaxAge'
>
> &
Pick<JwtVerifierOptions, 'agent' | 'secret'>;

export default ({
secret,
cooldownDuration,
timeoutDuration,
cacheMaxAge,
}: JWKSOptions) => {
let getKeyFn: GetKeyFn;
let prevjwksUri: string;

const secretKey = secret && createSecretKey(Buffer.from(secret));

return (jwksUri: string) => {
if (secretKey) return () => secretKey;
if (!getKeyFn || prevjwksUri !== jwksUri) {
prevjwksUri = jwksUri;
getKeyFn = createRemoteJWKSet(new URL(jwksUri), {
cooldownDuration,
timeoutDuration,
cacheMaxAge,
});
}
return getKeyFn;
};
};
69 changes: 37 additions & 32 deletions packages/access-token-jwt/src/jwt-verifier.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { strict as assert } from 'assert';
import { Buffer } from 'buffer';
import { createSecretKey } from 'crypto';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { URL } from 'url';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { JWTPayload, JWSHeaderParameters } from 'jose'
import { jwtVerify } from 'jose';
import type { JWTPayload, JWSHeaderParameters } from 'jose';
import { InvalidTokenError } from 'oauth2-bearer';
import discover, { IssuerMetadata } from './discovery';
import discovery from './discovery';
import getKeyFn from './get-key-fn';
import validate, { defaultValidators, Validators } from './validate';

export interface JwtVerifierOptions {
Expand Down Expand Up @@ -57,12 +55,19 @@ export interface JwtVerifierOptions {
cooldownDuration?: number;

/**
* Timeout in ms for the HTTP request. When reached the request will be
* aborted.
* Timeout in ms for HTTP requests to the JWKS and Discovery endpoint. When
* reached the request will be aborted.
* Default is 5000.
*/
timeoutDuration?: number;

/**
* Maximum time (in milliseconds) between successful HTTP requests to the
* JWKS and Discovery endpoint.
* Default is 600000 (10 minutes).
*/
cacheMaxAge?: number;

/**
* Pass in custom validators to override the existing validation behavior on
* standard claims or add new validation behavior on custom claims.
Expand Down Expand Up @@ -139,8 +144,6 @@ export interface VerifyJwtResult {

export type VerifyJwt = (jwt: string) => Promise<VerifyJwtResult>;

type GetKeyFn = ReturnType<typeof createRemoteJWKSet>;

const ASYMMETRIC_ALGS = [
'RS256',
'RS384',
Expand All @@ -166,13 +169,12 @@ const jwtVerifier = ({
agent,
cooldownDuration = 30000,
timeoutDuration = 5000,
cacheMaxAge = 600000,
clockTolerance = 5,
maxTokenAge,
strict = false,
validators: customValidators,
}: JwtVerifierOptions): VerifyJwt => {
let origJWKS: GetKeyFn;
let discovery: Promise<IssuerMetadata>;
let validators: Validators;
let allowedSigningAlgs: string[] | undefined;

Expand Down Expand Up @@ -202,30 +204,33 @@ const jwtVerifier = ({
)} for 'tokenSigningAlg' to validate symmetrically signed tokens`
);

const secretKey = secret && createSecretKey(Buffer.from(secret));
const getDiscovery = discovery({
issuerBaseURL,
agent,
timeoutDuration,
cacheMaxAge,
});

const JWKS = async (...args: Parameters<GetKeyFn>) => {
if (secretKey) return secretKey;
if (!origJWKS) {
origJWKS = createRemoteJWKSet(new URL(jwksUri), {
agent,
cooldownDuration,
timeoutDuration,
});
}
return origJWKS(...args);
};
const getKeyFnGetter = getKeyFn({
agent,
cooldownDuration,
timeoutDuration,
cacheMaxAge,
secret,
});

return async (jwt: string) => {
try {
if (issuerBaseURL) {
discovery =
discovery || discover(issuerBaseURL, { agent, timeoutDuration });
({
jwks_uri: jwksUri,
issuer,
id_token_signing_alg_values_supported: allowedSigningAlgs,
} = await discovery);
const {
jwks_uri: discoveredJwksUri,
issuer: discoveredIssuer,
id_token_signing_alg_values_supported:
idTokenSigningAlgValuesSupported,
} = await getDiscovery();
jwksUri = jwksUri || discoveredJwksUri;
issuer = issuer || discoveredIssuer;
allowedSigningAlgs = idTokenSigningAlgValuesSupported;
}
validators ||= {
...defaultValidators(
Expand All @@ -241,7 +246,7 @@ const jwtVerifier = ({
};
const { payload, protectedHeader: header } = await jwtVerify(
jwt,
JWKS,
getKeyFnGetter(jwksUri)
);
await validate(payload, header, validators);
return { payload, header, token: jwt };
Expand Down
125 changes: 123 additions & 2 deletions packages/access-token-jwt/test/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import nock = require('nock');
import { discover } from '../src';
import nock from 'nock';
import sinon from 'sinon';
import { discover as discovery } from '../src';

const success = { issuer: 'https://op.example.com' };

const discover = (
uri: string,
timeoutDuration = 5000,
cacheMaxAge = 600000
) => {
const getDiscovery = discovery({
issuerBaseURL: uri,
timeoutDuration,
cacheMaxAge,
});
return getDiscovery();
};

const mins = 60000;

describe('discover', () => {
afterEach(nock.cleanAll);

Expand Down Expand Up @@ -187,4 +203,109 @@ describe('discover', () => {
"'issuer' not found in authorization server metadata"
);
});

it('should cache discovery calls', async function () {
const spy = jest.fn(() => success);
nock('https://op.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, spy);

const getDiscovery = discovery({
issuerBaseURL: 'https://op.example.com/.well-known/openid-configuration',
timeoutDuration: 5000,
cacheMaxAge: 600000,
});

await getDiscovery();
await getDiscovery();
await getDiscovery();
expect(spy).toHaveBeenCalledTimes(1);
});

it('should handle concurrent discovery calls', async function () {
const spy = jest.fn(() => success);
nock('https://op.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, spy);

const getDiscovery = discovery({
issuerBaseURL: 'https://op.example.com/.well-known/openid-configuration',
timeoutDuration: 5000,
cacheMaxAge: 10 * mins,
});

await Promise.all([getDiscovery(), getDiscovery(), getDiscovery()]);
expect(spy).toHaveBeenCalledTimes(1);
});

it('should make new calls after max age', async function () {
const clock = sinon.useFakeTimers({
toFake: ['Date'],
});
const spy = jest.fn(() => success);
nock('https://op.example.com')
.persist()
.get('/.well-known/openid-configuration')
.reply(200, spy);

const getDiscovery = discovery({
issuerBaseURL: 'https://op.example.com/.well-known/openid-configuration',
timeoutDuration: 5000,
cacheMaxAge: 10 * mins,
});

await getDiscovery();
expect(spy).toHaveBeenCalledTimes(1);
clock.tick(5 * mins);
await getDiscovery();
expect(spy).toHaveBeenCalledTimes(1);

clock.tick(10 * mins);
await getDiscovery();
expect(spy).toHaveBeenCalledTimes(2);

clock.restore();
});

it('should not cache failed discovery calls', async function () {
nock('https://op.example.com')
.get('/.well-known/openid-configuration')
.reply(500);
nock('https://op.example.com')
.get('/.well-known/openid-configuration')
.reply(200, () => success);

const getDiscovery = discovery({
issuerBaseURL: 'https://op.example.com/.well-known/openid-configuration',
timeoutDuration: 5000,
cacheMaxAge: 600000,
});

await expect(getDiscovery()).rejects.toThrowError();
await expect(getDiscovery()).resolves.toMatchObject(success);
});

it('should handle concurrent client calls with failures', async function () {
nock('https://op.example.com')
.get('/.well-known/openid-configuration')
.reply(500);
nock('https://op.example.com')
.get('/.well-known/openid-configuration')
.reply(200, () => success);

const getDiscovery = discovery({
issuerBaseURL: 'https://op.example.com/.well-known/openid-configuration',
timeoutDuration: 5000,
cacheMaxAge: 600000,
});

await Promise.all([
expect(getDiscovery()).rejects.toThrowError(),
expect(getDiscovery()).rejects.toThrowError(),
expect(getDiscovery()).rejects.toThrowError(),
]);
await expect(getDiscovery()).resolves.toMatchObject(success);
});
});
Loading