From 5377fb8a7ed223ede65a9d83455dd803115c344a Mon Sep 17 00:00:00 2001 From: adamjmcgrath Date: Wed, 31 Aug 2022 10:30:44 +0100 Subject: [PATCH 1/4] Upgrade to jose@4 --- V2_MIGRATION_GUIDE.md | 39 +++++- package-lock.json | 104 +++++++--------- package.json | 5 +- src/auth0-session/cookie-store.ts | 70 +++++------ src/auth0-session/handlers/callback.ts | 12 +- src/auth0-session/handlers/login.ts | 8 +- src/auth0-session/handlers/logout.ts | 7 +- src/auth0-session/hooks/get-login-state.ts | 7 +- src/auth0-session/session-cache.ts | 8 +- src/auth0-session/transient-store.ts | 105 +++++++---------- src/auth0-session/utils/hkdf.ts | 9 +- src/auth0-session/utils/p-any.ts | 7 ++ src/handlers/profile.ts | 6 +- src/helpers/with-api-auth-required.ts | 25 ++-- src/helpers/with-page-auth-required.ts | 2 +- src/session/cache.ts | 38 +++--- src/session/get-access-token.ts | 4 +- src/session/get-session.ts | 4 +- src/session/update-user.ts | 10 +- tests/auth0-session/cookie-store.test.ts | 97 +++++++++------ tests/auth0-session/fixtures/cert.ts | 30 ++--- tests/auth0-session/fixtures/helpers.ts | 7 +- tests/auth0-session/fixtures/server.ts | 111 ++++++++---------- tests/auth0-session/handlers/callback.test.ts | 53 +++++---- tests/auth0-session/handlers/login.test.ts | 3 - tests/auth0-session/handlers/logout.test.ts | 8 +- tests/auth0-session/transient-store.test.ts | 52 ++++---- tests/auth0-session/utils/p-any.test.ts | 27 +++++ tests/fixtures/frontend.tsx | 10 +- tests/fixtures/oidc-nocks.ts | 12 +- tests/fixtures/setup.ts | 4 +- tests/fixtures/test-app/pages/api/session.ts | 4 +- .../test-app/pages/api/update-user.ts | 6 +- tests/handlers/callback.test.ts | 35 +++--- tests/handlers/logout.test.ts | 55 ++++----- tests/handlers/profile.test.ts | 4 +- tests/session/cache.test.ts | 58 ++++----- tests/session/get-access-token.test.ts | 12 +- tests/session/session.test.ts | 4 +- tests/session/update-user.test.ts | 8 +- 40 files changed, 563 insertions(+), 507 deletions(-) create mode 100644 src/auth0-session/utils/p-any.ts create mode 100644 tests/auth0-session/utils/p-any.test.ts diff --git a/V2_MIGRATION_GUIDE.md b/V2_MIGRATION_GUIDE.md index 61f11d89f..1dbccafeb 100644 --- a/V2_MIGRATION_GUIDE.md +++ b/V2_MIGRATION_GUIDE.md @@ -2,9 +2,36 @@ Guide to migrating from `1.x` to `2.x` +- [`getSession` now returns a `Promise`](#getsession-now-returns-a-promise) - [`updateUser` has been added](#updateuser-has-been-added) - [`getServerSidePropsWrapper` has been removed](#getserversidepropswrapper-has-been-removed) +## `getSession` now returns a `Promise` + +### Before + +```js +// /pages/api/my-api +import { getSession } from '@auth0/nextjs-auth0'; + +function myApiRoute(req, res) { + const session = getSession(req, res); + // ... +} +``` + +### After + +```js +// /pages/api/my-api +import { getSession } from '@auth0/nextjs-auth0'; + +async function myApiRoute(req, res) { + const session = await getSession(req, res); + // ... +} +``` + ## `updateUser` has been added ### Before @@ -28,17 +55,17 @@ function myApiRoute(req, res) { We've introduced a new `updateUser` method which must be explicitly invoked in order to update the session's user. -This will immediately serialise the session and write it to the cookie. +This will immediately serialise the session, write it to the cookie and return a `Promise`. ```js // /pages/api/update-user import { getSession, updateUser } from '@auth0/nextjs-auth0'; -function myApiRoute(req, res) { - const { user } = getSession(req, res); +async function myApiRoute(req, res) { + const { user } = await getSession(req, res); // The session is updated, serialized and the cookie is updated // everytime you call `updateUser`. - updateUser(req, res, { ...user, foo: 'bar' }); + await updateUser(req, res, { ...user, foo: 'bar' }); res.json({ success: true }); } ``` @@ -50,7 +77,7 @@ Because the process of modifying the session is now explicit, you no longer have ### Before ```js -export const getServerSideProps = getServerSidePropsWrapper(async (ctx) => { +export const getServerSideProps = getServerSidePropsWrapper((ctx) => { const session = getSession(ctx.req, ctx.res); if (session) { // User is authenticated @@ -64,7 +91,7 @@ export const getServerSideProps = getServerSidePropsWrapper(async (ctx) => { ```js export const getServerSideProps = async (ctx) => { - const session = getSession(ctx.req, ctx.res); + const session = await getSession(ctx.req, ctx.res); if (session) { // User is authenticated } else { diff --git a/package-lock.json b/package-lock.json index 1bc44e27c..ebc64485a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,12 @@ "version": "1.9.1", "license": "MIT", "dependencies": { - "base64url": "^3.0.1", + "@panva/hkdf": "^1.0.2", "cookie": "^0.5.0", "debug": "^4.3.4", - "futoin-hkdf": "^1.5.0", "http-errors": "^1.8.1", "joi": "^17.6.0", - "jose": "^2.0.5", + "jose": "^4.9.0", "openid-client": "^4.9.1", "tslib": "^2.4.0", "url-join": "^4.0.1" @@ -1803,6 +1802,14 @@ "node": ">=10.13.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.0.2.tgz", + "integrity": "sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@sideway/address": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", @@ -3323,14 +3330,6 @@ } ] }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -6286,14 +6285,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/futoin-hkdf": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.1.tgz", - "integrity": "sha512-g5d0Qp7ks55hYmYmfqn4Nz18XH49lcCR+vvIvHT92xXnsJaGZmY1EtWQWilJ6BQp57heCIXM/rRo+AFep8hGgg==", - "engines": { - "node": ">=8" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -9223,15 +9214,9 @@ } }, "node_modules/jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" - }, + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.0.tgz", + "integrity": "sha512-RgaqEOZLkVO+ViN3KkN44XJt9g7+wMveUv59sVLaTxONcUPc8ZpfqOCeLphVBZyih2dgkvZ0Ap1CNcokvY7Uyw==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -10342,15 +10327,6 @@ "node": ">= 0.6" } }, - "node_modules/oidc-provider/node_modules/jose": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", - "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/oidc-provider/node_modules/jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -10462,6 +10438,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -14742,6 +14732,11 @@ "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, + "@panva/hkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.0.2.tgz", + "integrity": "sha512-MSAs9t3Go7GUkMhpKC44T58DJ5KGk2vBo+h1cqQeqlMfdGkxaVB78ZWpv9gYi/g2fa4sopag9gJsNvS8XGgWJA==" + }, "@sideway/address": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", @@ -15942,11 +15937,6 @@ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true }, - "base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" - }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -18247,11 +18237,6 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, - "futoin-hkdf": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.1.tgz", - "integrity": "sha512-g5d0Qp7ks55hYmYmfqn4Nz18XH49lcCR+vvIvHT92xXnsJaGZmY1EtWQWilJ6BQp57heCIXM/rRo+AFep8hGgg==" - }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -20481,12 +20466,9 @@ } }, "jose": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", - "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", - "requires": { - "@panva/asn1.js": "^1.0.0" - } + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.0.tgz", + "integrity": "sha512-RgaqEOZLkVO+ViN3KkN44XJt9g7+wMveUv59sVLaTxONcUPc8ZpfqOCeLphVBZyih2dgkvZ0Ap1CNcokvY7Uyw==" }, "js-tokens": { "version": "4.0.0", @@ -21338,12 +21320,6 @@ "toidentifier": "1.0.0" } }, - "jose": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", - "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==", - "dev": true - }, "jsesc": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", @@ -21425,6 +21401,16 @@ "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" + }, + "dependencies": { + "jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + } } }, "optionator": { diff --git a/package.json b/package.json index f3b5ad9df..3d142da77 100644 --- a/package.json +++ b/package.json @@ -107,13 +107,12 @@ "typescript": "^4.1.3" }, "dependencies": { - "base64url": "^3.0.1", + "@panva/hkdf": "^1.0.2", "cookie": "^0.5.0", "debug": "^4.3.4", - "futoin-hkdf": "^1.5.0", "http-errors": "^1.8.1", "joi": "^17.6.0", - "jose": "^2.0.5", + "jose": "^4.9.0", "openid-client": "^4.9.1", "tslib": "^2.4.0", "url-join": "^4.0.1" diff --git a/src/auth0-session/cookie-store.ts b/src/auth0-session/cookie-store.ts index 529503416..2b9b73412 100644 --- a/src/auth0-session/cookie-store.ts +++ b/src/auth0-session/cookie-store.ts @@ -1,11 +1,12 @@ import { IncomingMessage, ServerResponse } from 'http'; import { strict as assert, AssertionError } from 'assert'; -import { JWE, JWK, JWKS, errors } from 'jose'; +import * as jose from 'jose'; +import { CookieSerializeOptions, serialize } from 'cookie'; import { encryption as deriveKey } from './utils/hkdf'; import createDebug from './utils/debug'; import Cookies from './utils/cookies'; +import pAny from './utils/p-any'; import { Config } from './config'; -import { CookieSerializeOptions, serialize } from 'cookie'; const debug = createDebug('cookie-store'); const epoch = (): number => (Date.now() / 1000) | 0; // eslint-disable-line no-bitwise @@ -13,27 +14,15 @@ const MAX_COOKIE_SIZE = 4096; const alg = 'dir'; const enc = 'A256GCM'; +type Header = { iat: number; uat: number; exp: number }; const notNull = (value: T | null): value is T => value !== null; export default class CookieStore { - private keystore: JWKS.KeyStore; - - private currentKey: JWK.OctKey | undefined; + private keys?: Uint8Array[]; private chunkSize: number; constructor(public config: Config) { - const secrets = Array.isArray(config.secret) ? config.secret : [config.secret]; - this.keystore = new JWKS.KeyStore(); - - secrets.forEach((secretString: string, i: number) => { - const key = JWK.asKey(deriveKey(secretString)); - if (i === 0) { - this.currentKey = key as JWK.OctKey; - } - this.keystore.add(key); - }); - const { cookie: { transient, ...cookieConfig }, name: sessionName @@ -49,20 +38,23 @@ export default class CookieStore { this.chunkSize = MAX_COOKIE_SIZE - emptyCookie.length; } - private encrypt(payload: string, headers: { [key: string]: any }): string { - return JWE.encrypt(payload, this.currentKey as JWK.OctKey, { - alg, - enc, - ...headers - }); + private async getKeys(): Promise { + if (!this.keys) { + const secret = this.config.secret; + const secrets = Array.isArray(secret) ? secret : [secret]; + this.keys = await Promise.all(secrets.map(deriveKey)); + } + return this.keys; + } + + private async encrypt(payload: jose.JWTPayload, { iat, uat, exp }: Header): Promise { + const [key] = await this.getKeys(); + return await new jose.EncryptJWT({ ...payload }).setProtectedHeader({ alg, enc, uat, iat, exp }).encrypt(key); } - private decrypt(jwe: string): JWE.completeDecrypt { - return JWE.decrypt(jwe, this.keystore, { - complete: true, - contentEncryptionAlgorithms: [enc], - keyManagementAlgorithms: [alg] - }); + private async decrypt(jwe: string): Promise { + const keys = await this.getKeys(); + return pAny(keys.map((key) => jose.compactDecrypt(jwe, key))) as Promise; } private calculateExp(iat: number, uat: number): number { @@ -78,13 +70,13 @@ export default class CookieStore { return Math.min(uat + (rollingDuration as number), iat + absoluteDuration); } - public read(req: IncomingMessage): [{ [key: string]: any }?, number?] { + public async read(req: any): Promise<[{ [key: string]: any }?, number?]> { const cookies = Cookies.getAll(req); const { name: sessionName, rollingDuration, absoluteDuration } = this.config.session; - let iat; - let uat; - let exp; + let iat: number; + let uat: number; + let exp: number; let existingSessionValue; try { @@ -118,8 +110,8 @@ export default class CookieStore { } if (existingSessionValue) { - const { protected: header, cleartext } = this.decrypt(existingSessionValue); - ({ iat, uat, exp } = header as { iat: number; uat: number; exp: number }); + const { protectedHeader: header, plaintext } = await this.decrypt(existingSessionValue); + ({ iat, uat, exp } = header as unknown as Header); // check that the existing session isn't expired based on options when it was established assert(exp > epoch(), 'it is expired based on options when it was established'); @@ -134,13 +126,13 @@ export default class CookieStore { assert(iat + absoluteDuration > epoch(), 'it is expired based on current absoluteDuration rules'); } - return [JSON.parse(cleartext.toString()), iat]; + return [JSON.parse(new TextDecoder().decode(plaintext)), iat]; } } catch (err) { /* istanbul ignore else */ if (err instanceof AssertionError) { debug('existing session was rejected because', err.message); - } else if (err instanceof errors.JOSEError) { + } else if (Array.isArray(err) && err[0] instanceof jose.errors.JOSEError) { debug('existing session was rejected because it could not be decrypted', err); } else { debug('unexpected error handling session', err); @@ -150,12 +142,12 @@ export default class CookieStore { return []; } - public save( + public async save( req: IncomingMessage, res: ServerResponse, session: { [key: string]: any } | undefined | null, createdAt?: number - ): void { + ): Promise { const { cookie: { transient, ...cookieConfig }, name: sessionName @@ -186,7 +178,7 @@ export default class CookieStore { } debug('found session, creating signed session cookie(s) with name %o(.i)', sessionName); - const value = this.encrypt(JSON.stringify(session), { iat, uat, exp }); + const value = await this.encrypt(session, { iat, uat, exp }); const chunkCount = Math.ceil(value.length / this.chunkSize); if (chunkCount > 1) { diff --git a/src/auth0-session/handlers/callback.ts b/src/auth0-session/handlers/callback.ts index 9e0e91682..606c933fc 100644 --- a/src/auth0-session/handlers/callback.ts +++ b/src/auth0-session/handlers/callback.ts @@ -40,10 +40,10 @@ export default function callbackHandlerFactory( let tokenSet; try { const callbackParams = client.callbackParams(req); - expectedState = transientCookieHandler.read('state', req, res); - const max_age = transientCookieHandler.read('max_age', req, res); - const code_verifier = transientCookieHandler.read('code_verifier', req, res); - const nonce = transientCookieHandler.read('nonce', req, res); + expectedState = await transientCookieHandler.read('state', req, res); + const max_age = await transientCookieHandler.read('max_age', req, res); + const code_verifier = await transientCookieHandler.read('code_verifier', req, res); + const nonce = await transientCookieHandler.read('nonce', req, res); tokenSet = await client.callback( redirectUri, @@ -65,13 +65,13 @@ export default function callbackHandlerFactory( } const openidState: { returnTo?: string } = decodeState(expectedState as string) as ValidState; - let session = sessionCache.fromTokenSet(tokenSet); + let session = await sessionCache.fromTokenSet(tokenSet); if (options?.afterCallback) { session = await options.afterCallback(req as any, res as any, session, openidState); } - sessionCache.create(req, res, session); + await sessionCache.create(req, res, session); res.writeHead(302, { Location: openidState.returnTo || config.baseURL diff --git a/src/auth0-session/handlers/login.ts b/src/auth0-session/handlers/login.ts index 630702609..bb605f590 100644 --- a/src/auth0-session/handlers/login.ts +++ b/src/auth0-session/handlers/login.ts @@ -58,15 +58,15 @@ export default function loginHandlerFactory( const authParams = { ...opts.authorizationParams, - nonce: transientHandler.save('nonce', req, res, transientOpts), - state: transientHandler.save('state', req, res, { + nonce: await transientHandler.save('nonce', req, res, transientOpts), + state: await transientHandler.save('state', req, res, { ...transientOpts, value: encodeState(stateValue) }), ...(usePKCE ? { code_challenge: transientHandler.calculateCodeChallenge( - transientHandler.save('code_verifier', req, res, transientOpts) + await transientHandler.save('code_verifier', req, res, transientOpts) ), code_challenge_method: 'S256' } @@ -81,7 +81,7 @@ export default function loginHandlerFactory( assert(/\bopenid\b/.test(authParams.scope as string), 'scope should contain "openid"'); if (authParams.max_age) { - transientHandler.save('max_age', req, res, { + await transientHandler.save('max_age', req, res, { ...transientOpts, value: authParams.max_age.toString() }); diff --git a/src/auth0-session/handlers/logout.ts b/src/auth0-session/handlers/logout.ts index 5e25691eb..41f142cc1 100644 --- a/src/auth0-session/handlers/logout.ts +++ b/src/auth0-session/handlers/logout.ts @@ -24,7 +24,8 @@ export default function logoutHandlerFactory( returnURL = urlJoin(config.baseURL, returnURL); } - if (!sessionCache.isAuthenticated(req, res)) { + const isAuthenticated = await sessionCache.isAuthenticated(req, res); + if (!isAuthenticated) { debug('end-user already logged out, redirecting to %s', returnURL); res.writeHead(302, { Location: returnURL @@ -33,8 +34,8 @@ export default function logoutHandlerFactory( return; } - const idToken = sessionCache.getIdToken(req, res); - sessionCache.delete(req, res); + const idToken = await sessionCache.getIdToken(req, res); + await sessionCache.delete(req, res); if (!config.idpLogout) { debug('performing a local only logout, redirecting to %s', returnURL); diff --git a/src/auth0-session/hooks/get-login-state.ts b/src/auth0-session/hooks/get-login-state.ts index 0ca8c92c4..a55a9ae74 100644 --- a/src/auth0-session/hooks/get-login-state.ts +++ b/src/auth0-session/hooks/get-login-state.ts @@ -1,6 +1,7 @@ -import base64url from 'base64url'; +import * as jose from 'jose'; import createDebug from '../utils/debug'; import { GetLoginState } from '../config'; +import { TextDecoder } from 'util'; const debug = createDebug('get-login-state'); @@ -32,7 +33,7 @@ export function encodeState(stateObject: { [key: string]: any }): string { // only stored in its dedicated transient cookie // eslint-disable-next-line @typescript-eslint/no-unused-vars const { nonce, code_verifier, max_age, ...filteredState } = stateObject; - return base64url.encode(JSON.stringify(filteredState)); + return jose.base64url.encode(JSON.stringify(filteredState)); } /** @@ -44,7 +45,7 @@ export function encodeState(stateObject: { [key: string]: any }): string { */ export function decodeState(stateValue?: string): { [key: string]: any } | undefined { try { - return JSON.parse(base64url.decode(stateValue as string)); + return JSON.parse(new TextDecoder().decode(jose.base64url.decode(stateValue as string))); } catch (e) { return undefined; } diff --git a/src/auth0-session/session-cache.ts b/src/auth0-session/session-cache.ts index a935f2aa8..1f0723903 100644 --- a/src/auth0-session/session-cache.ts +++ b/src/auth0-session/session-cache.ts @@ -2,9 +2,9 @@ import { IncomingMessage, ServerResponse } from 'http'; import { TokenSet } from 'openid-client'; export interface SessionCache { - create(req: IncomingMessage, res: ServerResponse, session: { [key: string]: any }): void; - delete(req: IncomingMessage, res: ServerResponse): void; - isAuthenticated(req: IncomingMessage, res: ServerResponse): boolean; - getIdToken(req: IncomingMessage, res: ServerResponse): string | undefined; + create(req: IncomingMessage, res: ServerResponse, session: { [key: string]: any }): Promise; + delete(req: IncomingMessage, res: ServerResponse): Promise; + isAuthenticated(req: IncomingMessage, res: ServerResponse): Promise; + getIdToken(req: IncomingMessage, res: ServerResponse): Promise; fromTokenSet(tokenSet: TokenSet): { [key: string]: any }; } diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts index d40a725ab..5ffadb213 100644 --- a/src/auth0-session/transient-store.ts +++ b/src/auth0-session/transient-store.ts @@ -1,6 +1,7 @@ import { IncomingMessage, ServerResponse } from 'http'; import { generators } from 'openid-client'; -import { JWKS, JWS, JWK } from 'jose'; +import * as jose from 'jose'; +import pAny from './utils/p-any'; import { signing as deriveKey } from './utils/hkdf'; import Cookies from './utils/cookies'; import { Config } from './config'; @@ -10,70 +11,52 @@ export interface StoreOptions { value?: string; } -const header = { alg: 'HS256', b64: false, crit: ['b64'] }; -const getPayload = (cookie: string, value: string): Buffer => Buffer.from(`${cookie}=${value}`); -const flattenedJWSFromCookie = (cookie: string, value: string, signature: string): JWS.FlattenedJWS => ({ - protected: Buffer.from(JSON.stringify(header)) - .toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'), - payload: getPayload(cookie, value), - signature -}); -const generateSignature = (cookie: string, value: string, key: JWK.Key): string => { - const payload = getPayload(cookie, value); - return JWS.sign.flattened(payload, key, header).signature; -}; -const verifySignature = (cookie: string, value: string, signature: string, keystore: JWKS.KeyStore): boolean => { - try { - return !!JWS.verify(flattenedJWSFromCookie(cookie, value, signature), keystore, { - algorithms: ['HS256'], - crit: ['b64'] - }); - } catch (err) { - return false; - } -}; -const getCookieValue = (cookie: string, value: string, keystore: JWKS.KeyStore): string | undefined => { - if (!value) { +const getCookieValue = async (k: string, v: string, keys: Uint8Array[]): Promise => { + if (!v) { return undefined; } - let signature; - [value, signature] = value.split('.'); - if (verifySignature(cookie, value, signature, keystore)) { + const [value, signature] = v.split('.'); + try { + const flattenedJWS = { + protected: jose.base64url.encode(JSON.stringify({ alg: 'HS256', b64: false, crit: ['b64'] })), + payload: `${k}=${value}`, + signature + }; + await pAny( + keys.map((key) => + jose.flattenedVerify(flattenedJWS, key, { + algorithms: ['HS256'], + crit: { + b64: false + } + }) + ) + ); return value; + } catch (err) { + return; } - - return undefined; }; -export const generateCookieValue = (cookie: string, value: string, key: JWK.Key): string => { - const signature = generateSignature(cookie, value, key); +export const generateCookieValue = async (cookie: string, value: string, key: Uint8Array): Promise => { + const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) + .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) + .sign(key); return `${value}.${signature}`; }; export default class TransientStore { - private currentKey: JWK.Key | undefined; - - private keyStore: JWKS.KeyStore; + private keys?: Uint8Array[]; - constructor(private config: Config) { - let current; + constructor(private config: Config) {} - const secret = config.secret; - const secrets = Array.isArray(secret) ? secret : [secret]; - const keystore = new JWKS.KeyStore(); - secrets.forEach((secretString, i) => { - const key = JWK.asKey(deriveKey(secretString)); - if (i === 0) { - current = key; - } - keystore.add(key); - }); - - this.currentKey = current; - this.keyStore = keystore; + private async getKeys(): Promise { + if (!this.keys) { + const secret = this.config.secret; + const secrets = Array.isArray(secret) ? secret : [secret]; + this.keys = await Promise.all(secrets.map(deriveKey)); + } + return this.keys; } /** @@ -88,12 +71,12 @@ export default class TransientStore { * * @return {String} Cookie value that was set. */ - save( + async save( key: string, _req: IncomingMessage, res: ServerResponse, { sameSite = 'none', value = this.generateNonce() }: StoreOptions - ): string { + ): Promise { const isSameSiteNone = sameSite === 'none'; const { domain, path, secure } = this.config.session.cookie; const basicAttr = { @@ -102,10 +85,11 @@ export default class TransientStore { domain, path }; + const [signingKey] = await this.getKeys(); const cookieSetter = new Cookies(); { - const cookieValue = generateCookieValue(key, value, this.currentKey as JWK.Key); + const cookieValue = await generateCookieValue(key, value, signingKey); // Set the cookie with the SameSite attribute and, if needed, the Secure flag. cookieSetter.set(key, cookieValue, { ...basicAttr, @@ -115,7 +99,7 @@ export default class TransientStore { } if (isSameSiteNone && this.config.legacySameSiteCookie) { - const cookieValue = generateCookieValue(`_${key}`, value, this.currentKey as JWK.Key); + const cookieValue = await generateCookieValue(`_${key}`, value, signingKey); // Set the fallback cookie with no SameSite or Secure attributes. cookieSetter.set(`_${key}`, cookieValue, basicAttr); } @@ -133,20 +117,21 @@ export default class TransientStore { * * @return {String|undefined} Cookie value or undefined if cookie was not found. */ - read(key: string, req: IncomingMessage, res: ServerResponse): string | undefined { + async read(key: string, req: IncomingMessage, res: ServerResponse): Promise { const cookies = Cookies.getAll(req); const cookie = cookies[key]; const cookieConfig = this.config.session.cookie; const cookieSetter = new Cookies(); - let value = getCookieValue(key, cookie, this.keyStore); + const verifyingKeys = await this.getKeys(); + let value = await getCookieValue(key, cookie, verifyingKeys); cookieSetter.clear(key, cookieConfig); if (this.config.legacySameSiteCookie) { const fallbackKey = `_${key}`; if (!value) { const fallbackCookie = cookies[fallbackKey]; - value = getCookieValue(fallbackKey, fallbackCookie, this.keyStore); + value = await getCookieValue(fallbackKey, fallbackCookie, verifyingKeys); } cookieSetter.clear(fallbackKey, cookieConfig); } diff --git a/src/auth0-session/utils/hkdf.ts b/src/auth0-session/utils/hkdf.ts index 6a6255158..c81f6f842 100644 --- a/src/auth0-session/utils/hkdf.ts +++ b/src/auth0-session/utils/hkdf.ts @@ -1,9 +1,9 @@ -import hkdf from 'futoin-hkdf'; +import hkdf from '@panva/hkdf'; const BYTE_LENGTH = 32; const ENCRYPTION_INFO = 'JWE CEK'; const SIGNING_INFO = 'JWS Cookie Signing'; -const options = { hash: 'SHA-256' }; +const digest = 'sha256'; /** * @@ -13,5 +13,6 @@ const options = { hash: 'SHA-256' }; * @see https://tools.ietf.org/html/rfc5869 * */ -export const encryption = (secret: string): Buffer => hkdf(secret, BYTE_LENGTH, { info: ENCRYPTION_INFO, ...options }); -export const signing = (secret: string): Buffer => hkdf(secret, BYTE_LENGTH, { info: SIGNING_INFO, ...options }); +export const encryption = (secret: string): Promise => + hkdf(digest, secret, '', ENCRYPTION_INFO, BYTE_LENGTH); +export const signing = (secret: string): Promise => hkdf(digest, secret, '', SIGNING_INFO, BYTE_LENGTH); diff --git a/src/auth0-session/utils/p-any.ts b/src/auth0-session/utils/p-any.ts new file mode 100644 index 000000000..1e416d0b0 --- /dev/null +++ b/src/auth0-session/utils/p-any.ts @@ -0,0 +1,7 @@ +const flip = (promise: Promise) => new Promise((a, b) => promise.then(b, a)); + +// Lightweight Promise.any-like implementation +// Promise.all returns the first rejected promise or all resolved promises +// Promise.any returns the first resolved promise or all rejected promises +// If we flip all the promises of Promise.all then flip back the result, we get the behaviour of Promise.any +export default async (promises: Promise[]) => flip(Promise.all(promises.map(flip))); diff --git a/src/handlers/profile.ts b/src/handlers/profile.ts index 45d0c89db..2b8f5b83e 100644 --- a/src/handlers/profile.ts +++ b/src/handlers/profile.ts @@ -45,7 +45,7 @@ export default function profileHandler( try { assertReqRes(req, res); - if (!sessionCache.isAuthenticated(req, res)) { + if (!(await sessionCache.isAuthenticated(req, res))) { res.status(401).json({ error: 'not_authenticated', description: 'The user does not have an active session or is not authenticated' @@ -53,7 +53,7 @@ export default function profileHandler( return; } - const session = sessionCache.get(req, res) as Session; + const session = (await sessionCache.get(req, res)) as Session; res.setHeader('Cache-Control', 'no-store'); if (options?.refetch) { @@ -77,7 +77,7 @@ export default function profileHandler( newSession = await options.afterRefetch(req, res, newSession); } - sessionCache.set(req, res, newSession); + await sessionCache.set(req, res, newSession); res.json(newSession.user); return; diff --git a/src/helpers/with-api-auth-required.ts b/src/helpers/with-api-auth-required.ts index 8799e2bc5..ccb6f735f 100644 --- a/src/helpers/with-api-auth-required.ts +++ b/src/helpers/with-api-auth-required.ts @@ -26,18 +26,19 @@ export type WithApiAuthRequired = (apiRoute: NextApiHandler) => NextApiHandler; * @ignore */ export default function withApiAuthFactory(sessionCache: SessionCache): WithApiAuthRequired { - return (apiRoute) => async (req: NextApiRequest, res: NextApiResponse): Promise => { - assertReqRes(req, res); + return (apiRoute) => + async (req: NextApiRequest, res: NextApiResponse): Promise => { + assertReqRes(req, res); - const session = sessionCache.get(req, res); - if (!session || !session.user) { - res.status(401).json({ - error: 'not_authenticated', - description: 'The user does not have an active session or is not authenticated' - }); - return; - } + const session = await sessionCache.get(req, res); + if (!session || !session.user) { + res.status(401).json({ + error: 'not_authenticated', + description: 'The user does not have an active session or is not authenticated' + }); + return; + } - await apiRoute(req, res); - }; + await apiRoute(req, res); + }; } diff --git a/src/helpers/with-page-auth-required.ts b/src/helpers/with-page-auth-required.ts index f5d01d7f4..027a82369 100644 --- a/src/helpers/with-page-auth-required.ts +++ b/src/helpers/with-page-auth-required.ts @@ -114,7 +114,7 @@ export default function withPageAuthRequiredFactory( return async (ctx: GetServerSidePropsContext): Promise => { assertCtx(ctx); const sessionCache = getSessionCache(); - const session = sessionCache.get(ctx.req, ctx.res); + const session = await sessionCache.get(ctx.req, ctx.res); if (!session?.user) { return { redirect: { diff --git a/src/session/cache.ts b/src/session/cache.ts index 773508ef0..6cc8a69f9 100644 --- a/src/session/cache.ts +++ b/src/session/cache.ts @@ -16,52 +16,52 @@ export default class SessionCache implements ISessionCache { this.iatCache = new WeakMap(); } - init(req: NextApiOrPageRequest, res: NextApiOrPageResponse, autoSave = true): void { + async init(req: NextApiOrPageRequest, res: NextApiOrPageResponse, autoSave = true): Promise { if (!this.cache.has(req)) { - const [json, iat] = this.cookieStore.read(req); + const [json, iat] = await this.cookieStore.read(req); this.iatCache.set(req, iat); this.cache.set(req, fromJson(json)); if (this.config.session.rolling && autoSave) { - this.save(req, res); + await this.save(req, res); } } } - save(req: NextApiOrPageRequest, res: NextApiOrPageResponse): void { - this.cookieStore.save(req, res, this.cache.get(req), this.iatCache.get(req)); + async save(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Promise { + await this.cookieStore.save(req, res, this.cache.get(req), this.iatCache.get(req)); } - create(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session): void { + async create(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session): Promise { this.cache.set(req, session); - this.save(req, res); + await this.save(req, res); } - delete(req: NextApiOrPageRequest, res: NextApiOrPageResponse): void { - this.init(req, res, false); + async delete(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Promise { + await this.init(req, res, false); this.cache.set(req, null); - this.save(req, res); + await this.save(req, res); } - isAuthenticated(req: NextApiOrPageRequest, res: NextApiOrPageResponse): boolean { - this.init(req, res); + async isAuthenticated(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Promise { + await this.init(req, res); const session = this.cache.get(req); return !!session?.user; } - getIdToken(req: NextApiOrPageRequest, res: NextApiOrPageResponse): string | undefined { - this.init(req, res); + async getIdToken(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Promise { + await this.init(req, res); const session = this.cache.get(req); return session?.idToken; } - set(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session | null): void { - this.init(req, res, false); + async set(req: NextApiOrPageRequest, res: NextApiOrPageResponse, session: Session | null): Promise { + await this.init(req, res, false); this.cache.set(req, session); - this.save(req, res); + await this.save(req, res); } - get(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Session | null | undefined { - this.init(req, res); + async get(req: NextApiOrPageRequest, res: NextApiOrPageResponse): Promise { + await this.init(req, res); return this.cache.get(req); } diff --git a/src/session/get-access-token.ts b/src/session/get-access-token.ts index b6e4ade20..b3c32dcdd 100644 --- a/src/session/get-access-token.ts +++ b/src/session/get-access-token.ts @@ -93,7 +93,7 @@ export default function accessTokenFactory( sessionCache: SessionCache ): GetAccessToken { return async (req, res, accessTokenRequest): Promise => { - let session = sessionCache.get(req, res); + let session = await sessionCache.get(req, res); if (!session) { throw new AccessTokenError(AccessTokenErrorCode.MISSING_SESSION, 'The user does not have a valid session.'); } @@ -173,7 +173,7 @@ export default function accessTokenFactory( session = await accessTokenRequest.afterRefresh(req as NextApiRequest, res as NextApiResponse, session); } - sessionCache.set(req, res, session); + await sessionCache.set(req, res, session); // Return the new access token. return { diff --git a/src/session/get-session.ts b/src/session/get-session.ts index 34f92982c..f119ee7b1 100644 --- a/src/session/get-session.ts +++ b/src/session/get-session.ts @@ -10,13 +10,13 @@ import { SessionCache, Session } from '../session'; export type GetSession = ( req: IncomingMessage | NextApiRequest, res: ServerResponse | NextApiResponse -) => Session | null | undefined; +) => Promise; /** * @ignore */ export default function sessionFactory(sessionCache: SessionCache): GetSession { - return (req, res): Session | null | undefined => { + return (req, res) => { return sessionCache.get(req, res); }; } diff --git a/src/session/update-user.ts b/src/session/update-user.ts index d06f5ce9d..39806f746 100644 --- a/src/session/update-user.ts +++ b/src/session/update-user.ts @@ -26,18 +26,18 @@ export type UpdateUser = ( req: IncomingMessage | NextApiRequest, res: ServerResponse | NextApiResponse, user: Claims -) => void; +) => Promise; /** * @ignore */ export default function updateUserFactory(sessionCache: SessionCache): UpdateUser { - return (req, res, user): void => { - sessionCache.init(req, res, false); - const session = sessionCache.get(req, res); + return async (req, res, user) => { + await sessionCache.init(req, res, false); + const session = await sessionCache.get(req, res); if (!session || !user) { return; } - sessionCache.set(req, res, { ...session, user }); + await sessionCache.set(req, res, { ...session, user }); }; } diff --git a/tests/auth0-session/cookie-store.test.ts b/tests/auth0-session/cookie-store.test.ts index cba683d60..e9b49ce2e 100644 --- a/tests/auth0-session/cookie-store.test.ts +++ b/tests/auth0-session/cookie-store.test.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'crypto'; -import { JWK, JWE } from 'jose'; +import * as jose from 'jose'; import { IdTokenClaims } from 'openid-client'; import { setup, teardown } from './fixtures/server'; import { defaultConfig, fromCookieJar, get, toCookieJar } from './fixtures/helpers'; @@ -8,28 +8,27 @@ import { makeIdToken } from './fixtures/cert'; const hr = 60 * 60 * 1000; const day = 24 * hr; -const key = JWK.asKey(deriveKey(defaultConfig.secret as string)); -const encrypted = (payload: Partial = { sub: '__test_sub__' }): string => { +const encrypted = async (claims: Partial = { sub: '__test_sub__' }): Promise => { + const key = await deriveKey(defaultConfig.secret as string); const epochNow = (Date.now() / 1000) | 0; const weekInSeconds = 7 * 24 * 60 * 60; - return JWE.encrypt( - JSON.stringify({ - access_token: '__test_access_token__', - token_type: 'Bearer', - id_token: makeIdToken(payload), - refresh_token: '__test_access_token__', - expires_at: epochNow + weekInSeconds - }), - key, - { + const payload = { + access_token: '__test_access_token__', + token_type: 'Bearer', + id_token: await makeIdToken(claims), + refresh_token: '__test_access_token__', + expires_at: epochNow + weekInSeconds + }; + return new jose.EncryptJWT({ ...payload }) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', uat: epochNow, iat: epochNow, exp: epochNow + weekInSeconds - } - ); + }) + .encrypt(key); }; describe('CookieStore', () => { @@ -53,13 +52,13 @@ describe('CookieStore', () => { it('should not error with JWEDecryptionFailed when using old secrets', async () => { const baseURL = await setup({ ...defaultConfig, secret: ['__invalid_secret__', '__also_invalid__'] }); - const cookieJar = toCookieJar({ appSession: encrypted() }, baseURL); + const cookieJar = toCookieJar({ appSession: await encrypted() }, baseURL); await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized'); }); it('should get an existing session', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); const session = await get(baseURL, '/session', { cookieJar }); expect(session).toMatchObject({ @@ -82,7 +81,7 @@ describe('CookieStore', () => { it('should chunk and accept chunked cookies over 4kb', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted({ + const appSession = await encrypted({ big_claim: randomBytes(2000).toString('base64') }); expect(appSession.length).toBeGreaterThan(4000); @@ -105,7 +104,7 @@ describe('CookieStore', () => { const path = '/some-really-really-really-really-really-really-really-really-really-really-really-really-really-long-path'; const baseURL = await setup({ ...defaultConfig, session: { cookie: { path } } }); - const appSession = encrypted({ + const appSession = await encrypted({ big_claim: randomBytes(5000).toString('base64') }); expect(appSession.length).toBeGreaterThan(4096); @@ -117,12 +116,12 @@ describe('CookieStore', () => { expect(cookies['appSession.0']).toHaveLength(4096); expect(cookies['appSession.1']).toHaveLength(4096); expect(cookies['appSession.2']).toHaveLength(4096); - expect(cookies['appSession.3']).toHaveLength(1568); + expect(cookies['appSession.3'].length).toBeLessThan(4096); }); it('should handle unordered chunked cookies', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted({ sub: '__chunked_sub__' }); + const appSession = await encrypted({ sub: '__chunked_sub__' }); const cookieJar = toCookieJar( { 'appSession.2': appSession.slice(20), @@ -150,7 +149,7 @@ describe('CookieStore', () => { it('should clean up single cookie when switching to chunked', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted({ + const appSession = await encrypted({ big_claim: randomBytes(2000).toString('base64') }); expect(appSession.length).toBeGreaterThan(4000); @@ -164,7 +163,7 @@ describe('CookieStore', () => { it('should clean up chunked cookies when switching to a single cookie', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted({ sub: 'foo' }); + const appSession = await encrypted({ sub: 'foo' }); const cookieJar = toCookieJar( { 'appSession.0': appSession.slice(0, 100), @@ -181,7 +180,7 @@ describe('CookieStore', () => { it('should set the default cookie options on http', async () => { const baseURL = await setup(defaultConfig); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -198,7 +197,7 @@ describe('CookieStore', () => { it('should set custom cookie options on http', async () => { const baseURL = await setup({ ...defaultConfig, session: { cookie: { httpOnly: false } } }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -209,7 +208,7 @@ describe('CookieStore', () => { it('should set the default cookie options on https', async () => { const baseURL = await setup(defaultConfig, { https: true }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -226,7 +225,7 @@ describe('CookieStore', () => { it('should set custom secure option on https', async () => { const baseURL = await setup({ ...defaultConfig, session: { cookie: { secure: false } } }, { https: true }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -238,7 +237,7 @@ describe('CookieStore', () => { it('should set custom sameSite option on https', async () => { const baseURL = await setup({ ...defaultConfig, session: { cookie: { sameSite: 'none' } } }, { https: true }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -250,7 +249,7 @@ describe('CookieStore', () => { it('should use a custom cookie name', async () => { const baseURL = await setup({ ...defaultConfig, session: { name: 'myCookie' } }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ myCookie: appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -261,7 +260,7 @@ describe('CookieStore', () => { it('should set an ephemeral cookie', async () => { const baseURL = await setup({ ...defaultConfig, session: { cookie: { transient: true } } }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await get(baseURL, '/session', { cookieJar }); const [cookie] = cookieJar.getCookiesSync(baseURL); @@ -274,12 +273,13 @@ describe('CookieStore', () => { const clock = jest.useFakeTimers('modern'); const baseURL = await setup(defaultConfig); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await expect(get(baseURL, '/session', { cookieJar })).resolves.not.toThrow(); jest.advanceTimersByTime(25 * hr); await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized'); clock.restoreAllMocks(); + jest.useRealTimers(); }); it('should expire after 7 days regardless of activity by default', async () => { @@ -287,7 +287,7 @@ describe('CookieStore', () => { let days = 7; const baseURL = await setup(defaultConfig); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); while (days--) { jest.advanceTimersByTime(23 * hr); @@ -296,6 +296,7 @@ describe('CookieStore', () => { jest.advanceTimersByTime(23 * hr); await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized'); clock.restoreAllMocks(); + jest.useRealTimers(); }); it('should expire only after custom absoluteDuration', async () => { @@ -308,7 +309,7 @@ describe('CookieStore', () => { absoluteDuration: (10 * day) / 1000 } }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); await expect(get(baseURL, '/session', { cookieJar })).resolves.not.toThrow(); jest.advanceTimersByTime(9 * day); @@ -316,6 +317,7 @@ describe('CookieStore', () => { jest.advanceTimersByTime(2 * day); await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized'); clock.restoreAllMocks(); + jest.useRealTimers(); }); it('should expire only after defined rollingDuration period of inactivty', async () => { @@ -331,7 +333,7 @@ describe('CookieStore', () => { } } }); - const appSession = encrypted(); + const appSession = await encrypted(); const cookieJar = toCookieJar({ appSession }, baseURL); let days = 30; while (days--) { @@ -341,5 +343,32 @@ describe('CookieStore', () => { jest.advanceTimersByTime(25 * hr); await expect(get(baseURL, '/session', { cookieJar })).rejects.toThrowError('Unauthorized'); clock.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('should not logout v1 users', async () => { + // Cookie generated with v1 cookie store tests with v long exp + const V1_COOKIE = + 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwidWF0IjoxNjYxODU5MTU3LCJpYXQiOjE2NjE4NTkxNTcsImV4cCI6MTY2MjQ2Mzk1N30..OCJcC6r7EGwTznTi.fgWVT4r9Rt8XMxooHCJcNiF5KGv7H7pg9WVygDCKfDb8qlPQQjANlKmweAoSnOTMdVweM3qZ9fvHrttlIk-kPmi3I5puMydfqihqMKRsIo9vbx24OdtUW-ZtzSqfc_nfMtX7qiu4u39FncNL2DeByTZSUUyDLf8S8V-FMakhjhPnOkweF3ztDnWaEK6w8Y_WsBJgFjFdbu8ZgKupcfKCwfxRR9xgUD9rWZ4AIuLhDId-jelRku9CqoCgL17DPbO2ytj1xs45LHL2sQGEaFLQFPZJ6bfNKdPtXQX73_nL3lqj20PqnvmzNW6DDYW0T3-kQz_VCEnBd74dtmGFwoMVbJ64Agvj55Gn_5aKFxBbdP5vb1mdKVTD7HdMfNnAPMPPyXsyvGHHaOPjnnkU8W_sNCaARS37FLWNxP59vNSvpSlN_oWCxsekHmkXVfhihaasO692eL319CPXfVa0Y3pQxUny6TunWv-HwtiV4GyrNG0ACL5gjVNS6qpcSuzOKn8NY8Y0FMnf_ISw8mz3Zel0WI_AJqU3IsGWdTHkF97ss5ckCyV0Ij9ezycbispxQ269rReUPE6Se_m5TqY7Py64MXS8ZgdG_KPrAGRP4I1KP0nLKU8NdaloI2I1HiiiDIC5hMhnmXtAvweXgOumWSACBu6PvcdGFdA-ptYaaT3vKC2-XxeVc7ynxabEeogcaXN1H_4wZ2Tjk5eLVTRTRnl0p09HBULoMr2KZAkDRjP3P-m5_Cne-1v9xGx23zzpxi3FfAH2jDBBSwEfy-GXxr65-hmIng7dOko4ul8AqWmP1f2sSYrBB-R3EZjVV0V6ssxC5I1q6Q-Xw99QsunlOYsTfikmBOvfXqNvFF1YkzsgYms6-NSSMmZmMy1huhfqfLvKuGKttqAtDlVByGQU2zF8VArYNEFc2TidtkewyzbrgWK0ygntJ17QeLMYNadNgz7eTSRwe7x-Vho_tB3XFoYPYpyA2JwIS4pb1KEdQQDevSp-_sjMbWpHnD1hruvqbCC7Zo795_N1OXt-kBbXddVsoXqzKmKJEZIPGvcJMLgeI5rLrw.c1K7B6p_vbSH9ZZrF8Uqvg'; + const baseURL = await setup(defaultConfig); + const appSession = V1_COOKIE; + const cookieJar = toCookieJar({ appSession }, baseURL); + const session = await get(baseURL, '/session', { cookieJar }); + expect(session).toMatchObject({ + access_token: '__test_access_token__', + token_type: 'Bearer', + id_token: expect.any(String), + refresh_token: '__test_access_token__', + expires_at: expect.any(Number), + claims: { + nickname: '__test_nickname__', + sub: '__test_sub__', + iss: 'https://op.example.com/', + aud: '__test_client_id__', + iat: expect.any(Number), + exp: expect.any(Number), + nonce: '__test_nonce__' + } + }); }); }); diff --git a/tests/auth0-session/fixtures/cert.ts b/tests/auth0-session/fixtures/cert.ts index 2820d7f77..0fd225698 100644 --- a/tests/auth0-session/fixtures/cert.ts +++ b/tests/auth0-session/fixtures/cert.ts @@ -1,13 +1,19 @@ -import { JWK, JWKS, JWT } from 'jose'; +import * as jose from 'jose'; import { IdTokenClaims } from 'openid-client'; -const k = JWK.asKey({ +const publicKey = { e: 'AQAB', n: 'wQrThQ9HKf8ksCQEzqOu0ofF8DtLJgexeFSQBNnMQetACzt4TbHPpjhTWUIlD8bFCkyx88d2_QV3TewMtfS649Pn5hV6adeYW2TxweAA8HVJxskc' + 'qTSa_ktojQ-cD43HIStsbqJhHoFv0UY6z5pwJrVPT-yt38ciKo9Oc9IhEl6TSw-zAnuNW0zPOhKjuiIqpAk1lT3e6cYv83ahx82vpx3ZnV83dT9u' + 'RbIbcgIpK4W64YnYb5uDH7hGI8-4GnalZDfdApTu-9Y8lg_1v5ul-eQDsLCkUCPkqBaNiCG3gfZUAKp9rrFRE_cJTv_MJn-y_XSTMWILvTY7vdSM' + 'RMo4kQ', + kty: 'RSA', + use: 'sig', + alg: 'RS256' +}; + +const privateKey = { d: 'EMHY1K8b1VhxndyykiGBVoM0uoLbJiT60eA9VD53za0XNSJncg8iYGJ5UcE9KF5v0lIQDIJfIN2tmpUIEW96HbbSZZWtt6xgbGaZ2eOREU6NJfVl' + 'SIbpgXOYUs5tFKiRBZ8YXY448gX4Z-k5x7W3UJTimqSH_2nw3FLuU32FI2vtf4ToUKEcoUdrIqoAwZ1et19E7Q_NCG2y1nez0LpD8PKgfeX1OVHd' + @@ -28,17 +34,12 @@ const k = JWK.asKey({ qi: '8hAW25CmPjLAXpzkMpXpXsvJKdgql0Zjt-OeSVwzQN5dLYmu-Q98Xl5n8H-Nfr8aOmPfHBQ8M9FOMpxbgg8gbqixpkrxcTIGjpuH8RFYXj_0TYSB' + 'kCSOoc7tAP7YjOUOGJMqFHDYZVD-gmsCuRwWx3jKFxRrWLS5b8kWzkON0bM', - kty: 'RSA', - use: 'sig', - alg: 'RS256' -}); - -export const jwks = new JWKS.KeyStore([k]).toJWKS(false); + ...publicKey +}; -export const key = k.toPEM(true); -export const kid = k.kid; +export const jwks = { keys: [publicKey] }; -export const makeIdToken = (payload?: Partial): string => { +export const makeIdToken = async (payload?: Partial): Promise => { payload = Object.assign( { nickname: '__test_nickname__', @@ -52,8 +53,7 @@ export const makeIdToken = (payload?: Partial): string => { payload ); - return JWT.sign(payload, k.toPEM(true), { - algorithm: 'RS256', - header: { kid: k.kid } - }); + return new jose.SignJWT(payload as IdTokenClaims) + .setProtectedHeader({ alg: 'RS256' }) + .sign(await jose.importJWK(privateKey)); }; diff --git a/tests/auth0-session/fixtures/helpers.ts b/tests/auth0-session/fixtures/helpers.ts index 8eb01c9be..7f7443163 100644 --- a/tests/auth0-session/fixtures/helpers.ts +++ b/tests/auth0-session/fixtures/helpers.ts @@ -1,5 +1,4 @@ import { Cookie, CookieJar } from 'tough-cookie'; -import { JWK } from 'jose'; import { signing as deriveKey } from '../../../src/auth0-session/utils/hkdf'; import { generateCookieValue } from '../../../src/auth0-session/transient-store'; import { IncomingMessage, request as nodeHttpRequest } from 'http'; @@ -17,11 +16,11 @@ export const defaultConfig: Omit = { } }; -export const toSignedCookieJar = (cookies: { [key: string]: string }, url: string): CookieJar => { +export const toSignedCookieJar = async (cookies: { [key: string]: string }, url: string): Promise => { const cookieJar = new CookieJar(); - const jwk = JWK.asKey(deriveKey(secret)); + const signingKey = await deriveKey(secret); for (const [key, value] of Object.entries(cookies)) { - cookieJar.setCookieSync(`${key}=${generateCookieValue(key, value, jwk)}`, url); + cookieJar.setCookieSync(`${key}=${await generateCookieValue(key, value, signingKey)}`, url); } return cookieJar; }; diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts index 665dffed8..1469bfcb5 100644 --- a/tests/auth0-session/fixtures/server.ts +++ b/tests/auth0-session/fixtures/server.ts @@ -4,7 +4,6 @@ import { createServer as createHttpsServer, Server as HttpsServer } from 'https' import url from 'url'; import nock from 'nock'; import { TokenSet, TokenSetParameters } from 'openid-client'; -import onHeaders from 'on-headers'; import bodyParser from 'body-parser'; import { loginHandler, @@ -29,21 +28,20 @@ import version from '../../../src/version'; export type SessionResponse = TokenSetParameters & { claims: Claims }; class TestSessionCache implements SessionCache { - public cache: WeakMap; - constructor() { - this.cache = new WeakMap(); + constructor(private cookieStore: CookieStore) {} + async create(req: IncomingMessage, res: ServerResponse, tokenSet: TokenSet): Promise { + await this.cookieStore.save(req, res, tokenSet); } - create(req: IncomingMessage, _res: ServerResponse, tokenSet: TokenSet): void { - this.cache.set(req, tokenSet); + async delete(req: IncomingMessage, res: ServerResponse): Promise { + await this.cookieStore.save(req, res, null); } - delete(req: IncomingMessage): void { - this.cache.delete(req); + async isAuthenticated(req: IncomingMessage): Promise { + const [session] = await this.cookieStore.read(req); + return !!session?.id_token; } - isAuthenticated(req: IncomingMessage): boolean { - return !!this.cache.get(req)?.id_token; - } - getIdToken(req: IncomingMessage): string | undefined { - return this.cache.get(req)?.id_token; + async getIdToken(req: IncomingMessage): Promise { + const [session] = await this.cookieStore.read(req); + return session?.id_token; } fromTokenSet(tokenSet: TokenSet): { [p: string]: any } { return tokenSet; @@ -62,31 +60,24 @@ const createHandlers = (params: ConfigParameters): Handlers => { const getClient = clientFactory(config, { name: 'nextjs-auth0', version }); const transientStore = new TransientStore(config); const cookieStore = new CookieStore(config); - const sessionCache = new TestSessionCache(); - - const applyCookies = (fn: Function) => (req: IncomingMessage, res: ServerResponse, ...args: []): any => { - if (!sessionCache.cache.has(req)) { - const [json, iat] = cookieStore.read(req); - sessionCache.cache.set(req, new TokenSet(json)); - onHeaders(res, () => cookieStore.save(req, res, sessionCache.cache.get(req), iat)); - } - return fn(req, res, ...args); - }; + const sessionCache = new TestSessionCache(cookieStore); return { - handleLogin: applyCookies(loginHandler(config, getClient, transientStore)), - handleLogout: applyCookies(logoutHandler(config, getClient, sessionCache)), - handleCallback: applyCookies(callbackHandler(config, getClient, sessionCache, transientStore)), - handleSession: applyCookies((req: IncomingMessage, res: ServerResponse) => { - if (!sessionCache.isAuthenticated(req)) { + handleLogin: loginHandler(config, getClient, transientStore), + handleLogout: logoutHandler(config, getClient, sessionCache), + handleCallback: callbackHandler(config, getClient, sessionCache, transientStore), + handleSession: async (req: IncomingMessage, res: ServerResponse) => { + const [json, iat] = await cookieStore.read(req); + if (!json?.id_token) { res.writeHead(401); res.end(); return; } - const session = sessionCache.cache.get(req); + const session = new TokenSet(json); + await cookieStore.save(req, res, session, iat); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ ...session, claims: session?.claims() } as SessionResponse)); - }) + } }; }; @@ -102,36 +93,38 @@ const parseJson = (req: IncomingMessage, res: ServerResponse): Promise async (req: IncomingMessage, res: ServerResponse): Promise => { - const { pathname } = url.parse(req.url as string, true); - const parsedReq = await parseJson(req, res); - - try { - switch (pathname) { - case '/login': - return await handlers.handleLogin(parsedReq, res, loginOptions); - case '/logout': - return await handlers.handleLogout(parsedReq, res, logoutOptions); - case '/callback': - return await handlers.handleCallback(parsedReq, res, callbackOptions); - case '/session': - return await handlers.handleSession(parsedReq, res); - default: - res.writeHead(404); - res.end(); +const requestListener = + ( + handlers: Handlers, + { + callbackOptions, + loginOptions, + logoutOptions + }: { callbackOptions?: CallbackOptions; loginOptions?: LoginOptions; logoutOptions?: LogoutOptions } + ) => + async (req: IncomingMessage, res: ServerResponse): Promise => { + const { pathname } = url.parse(req.url as string, true); + const parsedReq = await parseJson(req, res); + + try { + switch (pathname) { + case '/login': + return await handlers.handleLogin(parsedReq, res, loginOptions); + case '/logout': + return await handlers.handleLogout(parsedReq, res, logoutOptions); + case '/callback': + return await handlers.handleCallback(parsedReq, res, callbackOptions); + case '/session': + return await handlers.handleSession(parsedReq, res); + default: + res.writeHead(404); + res.end(); + } + } catch (e) { + res.writeHead(e.statusCode || 500, e.message); + res.end(); } - } catch (e) { - res.writeHead(e.statusCode || 500, e.message); - res.end(); - } -}; + }; let server: HttpServer | HttpsServer; diff --git a/tests/auth0-session/handlers/callback.test.ts b/tests/auth0-session/handlers/callback.test.ts index 894b10363..f7b1794c6 100644 --- a/tests/auth0-session/handlers/callback.test.ts +++ b/tests/auth0-session/handlers/callback.test.ts @@ -1,6 +1,7 @@ import nock from 'nock'; import { CookieJar } from 'tough-cookie'; -import { JWT } from 'jose'; +import * as jose from 'jose'; +import { signing as deriveKey } from '../../../src/auth0-session/utils/hkdf'; import { encodeState } from '../../../src/auth0-session/hooks/get-login-state'; import { SessionResponse, setup, teardown } from '../fixtures/server'; import { makeIdToken } from '../fixtures/cert'; @@ -14,7 +15,7 @@ describe('callback', () => { it('should error when the body is empty', async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { nonce: '__test_nonce__', state: '__test_state__' @@ -44,7 +45,7 @@ describe('callback', () => { it("should error when state doesn't match", async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { nonce: '__valid_nonce__', state: '__valid_state__' @@ -66,7 +67,7 @@ describe('callback', () => { it("should error when id_token can't be parsed", async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { nonce: '__valid_nonce__', state: '__valid_state__' @@ -88,7 +89,7 @@ describe('callback', () => { it('should error when id_token has invalid alg', async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { nonce: '__valid_nonce__', state: '__valid_state__' @@ -100,9 +101,9 @@ describe('callback', () => { post(baseURL, '/callback', { body: { state: '__valid_state__', - id_token: JWT.sign({ sub: '__test_sub__' }, 'secret', { - algorithm: 'HS256' - }) + id_token: await new jose.SignJWT({ sub: '__test_sub__' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(await deriveKey('secret')) }, cookieJar }) @@ -112,7 +113,7 @@ describe('callback', () => { it('should error when id_token is missing issuer', async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { nonce: '__valid_nonce__', state: '__valid_state__' @@ -124,7 +125,7 @@ describe('callback', () => { post(baseURL, '/callback', { body: { state: '__valid_state__', - id_token: makeIdToken({ iss: undefined }) + id_token: await makeIdToken({ iss: undefined }) }, cookieJar }) @@ -134,7 +135,7 @@ describe('callback', () => { it('should error when nonce is missing from cookies', async () => { const baseURL = await setup(defaultConfig); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: '__valid_state__' }, @@ -145,7 +146,7 @@ describe('callback', () => { post(baseURL, '/callback', { body: { state: '__valid_state__', - id_token: makeIdToken({ nonce: '__test_nonce__' }) + id_token: await makeIdToken({ nonce: '__test_nonce__' }) }, cookieJar }) @@ -155,7 +156,7 @@ describe('callback', () => { it('should error when legacy samesite fallback is off', async () => { const baseURL = await setup({ ...defaultConfig, legacySameSiteCookie: false }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { _state: '__valid_state__' }, @@ -166,7 +167,7 @@ describe('callback', () => { post(baseURL, '/callback', { body: { state: '__valid_state__', - id_token: makeIdToken() + id_token: await makeIdToken() }, cookieJar }) @@ -185,7 +186,7 @@ describe('callback', () => { auth_time: 10 }; - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: expectedDefaultState, nonce: '__test_nonce__', @@ -198,7 +199,7 @@ describe('callback', () => { post(baseURL, '/callback', { body: { state: expectedDefaultState, - id_token: makeIdToken(expected) + id_token: await makeIdToken(expected) }, cookieJar }) @@ -216,7 +217,7 @@ describe('callback', () => { nonce: '__test_nonce__' }; - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: expectedDefaultState, nonce: '__test_nonce__' @@ -227,7 +228,7 @@ describe('callback', () => { const { res } = await post(baseURL, '/callback', { body: { state: expectedDefaultState, - id_token: makeIdToken(expected) + id_token: await makeIdToken(expected) }, cookieJar, fullResponse: true @@ -250,7 +251,7 @@ describe('callback', () => { } }); - const idToken = makeIdToken({ + const idToken = await makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ' }); @@ -264,7 +265,7 @@ describe('callback', () => { expires_in: 86400 })); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: expectedDefaultState, nonce: '__test_nonce__' @@ -294,7 +295,7 @@ describe('callback', () => { }); it('should use basic auth on token endpoint when using code flow', async () => { - const idToken = makeIdToken({ + const idToken = await makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ' }); @@ -324,7 +325,7 @@ describe('callback', () => { }; }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: expectedDefaultState, nonce: '__test_nonce__' @@ -352,7 +353,7 @@ describe('callback', () => { const baseURL = await setup(defaultConfig); const state = encodeState({ foo: 'bar' }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: state, nonce: '__test_nonce__' @@ -363,7 +364,7 @@ describe('callback', () => { const { res } = await post(baseURL, '/callback', { body: { state: state, - id_token: makeIdToken() + id_token: await makeIdToken() }, cookieJar, fullResponse: true @@ -377,11 +378,11 @@ describe('callback', () => { const redirectUri = 'http://messi:3000/api/auth/callback/runtime'; const baseURL = await setup(defaultConfig, { callbackOptions: { redirectUri } }); const state = encodeState({ foo: 'bar' }); - const cookieJar = toSignedCookieJar({ state, nonce: '__test_nonce__' }, baseURL); + const cookieJar = await toSignedCookieJar({ state, nonce: '__test_nonce__' }, baseURL); const { res } = await post(baseURL, '/callback', { body: { state: state, - id_token: makeIdToken() + id_token: await makeIdToken() }, cookieJar, fullResponse: true diff --git a/tests/auth0-session/handlers/login.test.ts b/tests/auth0-session/handlers/login.test.ts index 6d2ed956b..c6d0fa6a7 100644 --- a/tests/auth0-session/handlers/login.test.ts +++ b/tests/auth0-session/handlers/login.test.ts @@ -34,7 +34,6 @@ describe('login', () => { }); expect(fromCookieJar(cookieJar, baseURL)).toMatchObject({ - appSession: expect.any(String), _state: parsed.query.state, _nonce: parsed.query.nonce }); @@ -72,7 +71,6 @@ describe('login', () => { }); expect(fromCookieJar(cookieJar, baseURL)).toMatchObject({ - appSession: expect.any(String), code_verifier: expect.any(String), state: parsed.query.state, nonce: parsed.query.nonce @@ -111,7 +109,6 @@ describe('login', () => { }); expect(fromCookieJar(cookieJar, baseURL)).toMatchObject({ - appSession: expect.any(String), _code_verifier: expect.any(String), _state: parsed.query.state, _nonce: parsed.query.nonce diff --git a/tests/auth0-session/handlers/logout.test.ts b/tests/auth0-session/handlers/logout.test.ts index 74fafc459..3aa2c5f80 100644 --- a/tests/auth0-session/handlers/logout.test.ts +++ b/tests/auth0-session/handlers/logout.test.ts @@ -8,11 +8,11 @@ import { encodeState } from '../../../src/auth0-session/hooks/get-login-state'; const login = async (baseURL: string): Promise => { const nonce = '__test_nonce__'; const state = encodeState({ returnTo: 'https://example.org' }); - const cookieJar = toSignedCookieJar({ state, nonce }, baseURL); + const cookieJar = await toSignedCookieJar({ state, nonce }, baseURL); await post(baseURL, '/callback', { body: { state, - id_token: makeIdToken({ nonce }) + id_token: await makeIdToken({ nonce }) }, cookieJar }); @@ -87,11 +87,11 @@ describe('logout route', () => { }); const nonce = '__test_nonce__'; const state = encodeState({ returnTo: 'https://example.org' }); - const cookieJar = toSignedCookieJar({ state, nonce }, baseURL); + const cookieJar = await toSignedCookieJar({ state, nonce }, baseURL); await post(baseURL, '/callback', { body: { state, - id_token: makeIdToken({ nonce, iss: 'https://test.eu.auth0.com/' }) + id_token: await makeIdToken({ nonce, iss: 'https://test.eu.auth0.com/' }) }, cookieJar }); diff --git a/tests/auth0-session/transient-store.test.ts b/tests/auth0-session/transient-store.test.ts index de0d8ea01..3863116b2 100644 --- a/tests/auth0-session/transient-store.test.ts +++ b/tests/auth0-session/transient-store.test.ts @@ -1,24 +1,24 @@ import { IncomingMessage, ServerResponse } from 'http'; -import { JWK, JWS } from 'jose'; +import * as jose from 'jose'; import { CookieJar } from 'tough-cookie'; -import { getConfig, TransientStore } from '../../src/auth0-session'; +import { getConfig, TransientStore } from '../../src/auth0-session/'; import { signing as deriveKey } from '../../src/auth0-session/utils/hkdf'; import { defaultConfig, fromCookieJar, get, getCookie, toSignedCookieJar } from './fixtures/helpers'; import { setup as createServer, teardown } from './fixtures/server'; -const generateSignature = (cookie: string, value: string): string => { - const key = JWK.asKey(deriveKey(defaultConfig.secret as string)); - return JWS.sign.flattened(Buffer.from(`${cookie}=${value}`), key, { - alg: 'HS256', - b64: false, - crit: ['b64'] - }).signature; +const generateSignature = async (cookie: string, value: string): Promise => { + const key = await deriveKey(defaultConfig.secret as string); + const { signature } = await new jose.FlattenedSign(new TextEncoder().encode(`${cookie}=${value}`)) + .setProtectedHeader({ alg: 'HS256', b64: false, crit: ['b64'] }) + .sign(key); + return signature; }; const setup = async (params = defaultConfig, cb: Function, https = true): Promise => createServer(params, { - customListener: (req, res) => { - res.end(JSON.stringify({ value: cb(req, res) })); + customListener: async (req, res) => { + const value = await cb(req, res); + res.end(JSON.stringify({ value })); }, https }); @@ -27,8 +27,10 @@ describe('TransientStore', () => { afterEach(teardown); it('should use the passed-in key to set the cookies', async () => { - const baseURL = await setup(defaultConfig, (req: IncomingMessage, res: ServerResponse) => - transientStore.save('test_key', req, res, { value: 'foo' }) + const baseURL = await setup( + defaultConfig, + async (req: IncomingMessage, res: ServerResponse) => + await transientStore.save('test_key', req, res, { value: 'foo' }) ); const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL })); const cookieJar = new CookieJar(); @@ -40,11 +42,11 @@ describe('TransientStore', () => { }); it('should accept list of secrets', async () => { - const baseURL = await setup( - { ...defaultConfig, secret: ['__old_secret__', defaultConfig.secret as string] }, - (req: IncomingMessage, res: ServerResponse) => transientStore.save('test_key', req, res, { value: 'foo' }) + const config = { ...defaultConfig, secret: ['__old_secret__', defaultConfig.secret as string] }; + const baseURL = await setup(config, (req: IncomingMessage, res: ServerResponse) => + transientStore.save('test_key', req, res, { value: 'foo' }) ); - const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL })); + const transientStore = new TransientStore(getConfig({ ...config, baseURL })); const cookieJar = new CookieJar(); const { value } = await get(baseURL, '/', { cookieJar }); const cookies = fromCookieJar(cookieJar, baseURL); @@ -157,10 +159,10 @@ describe('TransientStore', () => { transientStore.read('test_key', req, res) ); const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL })); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { - test_key: `foo.${generateSignature('test_key', 'foo')}`, - _test_key: `foo.${generateSignature('_test_key', 'foo')}` + test_key: `foo.${await generateSignature('test_key', 'foo')}`, + _test_key: `foo.${await generateSignature('_test_key', 'foo')}` }, baseURL ); @@ -177,9 +179,9 @@ describe('TransientStore', () => { transientStore.read('test_key', req, res) ); const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL })); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { - _test_key: `foo.${generateSignature('_test_key', 'foo')}` + _test_key: `foo.${await generateSignature('_test_key', 'foo')}` }, baseURL ); @@ -196,9 +198,9 @@ describe('TransientStore', () => { (req: IncomingMessage, res: ServerResponse) => transientStore.read('test_key', req, res) ); const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL, legacySameSiteCookie: false })); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { - _test_key: `foo.${generateSignature('_test_key', 'foo')}` + _test_key: `foo.${await generateSignature('_test_key', 'foo')}` }, baseURL ); @@ -213,7 +215,7 @@ describe('TransientStore', () => { transientStore.read('test_key', req, res) ); const transientStore = new TransientStore(getConfig({ ...defaultConfig, baseURL })); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { test_key: 'foo.bar', _test_key: 'foo.bar' diff --git a/tests/auth0-session/utils/p-any.test.ts b/tests/auth0-session/utils/p-any.test.ts new file mode 100644 index 000000000..6467e1325 --- /dev/null +++ b/tests/auth0-session/utils/p-any.test.ts @@ -0,0 +1,27 @@ +import pAny from '../../../src/auth0-session/utils/p-any'; + +const delay = (ms: number, { value }: { value: number }) => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + +describe('p-any', () => { + it('returns the first fulfilled value', async () => { + const spy = jest.fn(); + const fixture = [ + Promise.reject(new Error('1')), + Promise.resolve(2), + Promise.reject(new Error('3')).finally(spy), + Promise.resolve(4) + ]; + await expect(pAny(fixture)).resolves.toEqual(2); + }); + + it('returns the first fulfilled value #2', async () => { + const fixture = [delay(100, { value: 1 }), delay(10, { value: 2 }), delay(50, { value: 3 })]; + await expect(pAny(fixture)).resolves.toEqual(2); + }); + + it('rejects with errors', async () => { + const fixture = [Promise.reject(new Error('1')), Promise.reject(new Error('2')), Promise.reject(new Error('3'))]; + await expect(pAny(fixture)).rejects.toEqual(['1', '2', '3'].map(Error)); + }); +}); diff --git a/tests/fixtures/frontend.tsx b/tests/fixtures/frontend.tsx index 222964b5a..e9afdaa7d 100644 --- a/tests/fixtures/frontend.tsx +++ b/tests/fixtures/frontend.tsx @@ -1,7 +1,13 @@ import React from 'react'; -import { UserProvider, UserProviderProps, UserProfile } from '../../src'; -import { ConfigProvider, ConfigProviderProps, RequestError } from '../../src/frontend'; +import { + ConfigProvider, + ConfigProviderProps, + RequestError, + UserProvider, + UserProviderProps, + UserProfile +} from '../../src/frontend'; type FetchUserMock = { ok: boolean; diff --git a/tests/fixtures/oidc-nocks.ts b/tests/fixtures/oidc-nocks.ts index 2b19cf96d..d8c445854 100644 --- a/tests/fixtures/oidc-nocks.ts +++ b/tests/fixtures/oidc-nocks.ts @@ -97,13 +97,13 @@ export function codeExchange(params: ConfigParameters, idToken: string, code = ' }); } -export function refreshTokenExchange( +export async function refreshTokenExchange( params: ConfigParameters, refreshToken: string, payload: Record, newToken?: string -): nock.Scope { - const idToken = makeIdToken({ +): Promise { + const idToken = await makeIdToken({ iss: `${params.issuerBaseURL}/`, aud: params.clientID, ...payload @@ -120,14 +120,14 @@ export function refreshTokenExchange( }); } -export function refreshTokenRotationExchange( +export async function refreshTokenRotationExchange( params: ConfigParameters, refreshToken: string, payload: Record, newToken?: string, newrefreshToken?: string -): nock.Scope { - const idToken = makeIdToken({ +): Promise { + const idToken = await makeIdToken({ iss: `${params.issuerBaseURL}/`, aud: params.clientID, ...payload diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts index eca230423..8c1635751 100644 --- a/tests/fixtures/setup.ts +++ b/tests/fixtures/setup.ts @@ -51,7 +51,7 @@ export const setup = async ( ): Promise => { discovery(config, discoveryOptions); jwksEndpoint(config, jwks); - codeExchange(config, makeIdToken({ iss: 'https://acme.auth0.local/', ...idTokenClaims })); + codeExchange(config, await makeIdToken({ iss: 'https://acme.auth0.local/', ...idTokenClaims })); userInfo(config, userInfoToken, userInfoPayload); const { handleAuth, @@ -126,7 +126,7 @@ export const teardown = async (): Promise => { export const login = async (baseUrl: string): Promise => { const nonce = '__test_nonce__'; const state = encodeState({ returnTo: '/' }); - const cookieJar = toSignedCookieJar({ state, nonce }, baseUrl); + const cookieJar = await toSignedCookieJar({ state, nonce }, baseUrl); await post(baseUrl, '/api/auth/callback', { fullResponse: true, body: { diff --git a/tests/fixtures/test-app/pages/api/session.ts b/tests/fixtures/test-app/pages/api/session.ts index d356905af..e451f9e84 100644 --- a/tests/fixtures/test-app/pages/api/session.ts +++ b/tests/fixtures/test-app/pages/api/session.ts @@ -1,6 +1,6 @@ import { NextApiRequest, NextApiResponse } from 'next'; -export default function sessionHandler(req: NextApiRequest, res: NextApiResponse): void { - const json = (global as any).getSession(req, res); +export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse) { + const json = await (global as any).getSession(req, res); res.status(200).json(json); } diff --git a/tests/fixtures/test-app/pages/api/update-user.ts b/tests/fixtures/test-app/pages/api/update-user.ts index a33bf15a0..ce8f82ac2 100644 --- a/tests/fixtures/test-app/pages/api/update-user.ts +++ b/tests/fixtures/test-app/pages/api/update-user.ts @@ -1,8 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; -export default function sessionHandler(req: NextApiRequest, res: NextApiResponse): void { - const session = (global as any).getSession(req, res); +export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse) { + const session = await (global as any).getSession(req, res); const updated = { ...session?.user, ...req.body?.user }; - (global as any).updateUser(req, res, updated); + await (global as any).updateUser(req, res, updated); res.status(200).json(updated); } diff --git a/tests/handlers/callback.test.ts b/tests/handlers/callback.test.ts index 5b2a5bba8..4d2714562 100644 --- a/tests/handlers/callback.test.ts +++ b/tests/handlers/callback.test.ts @@ -29,7 +29,7 @@ describe('callback handler', () => { test('should validate the state', async () => { const baseUrl = await setup(withoutApi); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state: '__other_state__' }, @@ -49,7 +49,7 @@ describe('callback handler', () => { test('should validate the audience', async () => { const baseUrl = await setup(withoutApi, { idTokenClaims: { aud: 'bar' } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -71,7 +71,7 @@ describe('callback handler', () => { test('should validate the issuer', async () => { const baseUrl = await setup(withoutApi, { idTokenClaims: { aud: 'bar', iss: 'other-issuer' } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -100,7 +100,7 @@ describe('callback handler', () => { test('should create the session without OIDC claims', async () => { const baseUrl = await setup(withoutApi); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -117,7 +117,6 @@ describe('callback handler', () => { ); expect(res.statusCode).toBe(302); const body = await get(baseUrl, `/api/session`, { cookieJar }); - expect(body.user).toStrictEqual({ nickname: '__test_nickname__', sub: '__test_sub__' @@ -128,7 +127,7 @@ describe('callback handler', () => { timekeeper.freeze(0); const baseUrl = await setup(withoutApi); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -155,7 +154,7 @@ describe('callback handler', () => { timekeeper.freeze(0); const baseUrl = await setup(withApi); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -177,7 +176,7 @@ describe('callback handler', () => { accessToken: 'eyJz93a...k4laUWw', accessTokenExpiresAt: 750, accessTokenScope: 'read:foo delete:foo', - idToken: makeIdToken({ iss: 'https://acme.auth0.local/' }), + idToken: await makeIdToken({ iss: 'https://acme.auth0.local/' }), token_type: 'Bearer', refreshToken: 'GEbRxBN...edjnXbL', user: { @@ -197,7 +196,7 @@ describe('callback handler', () => { }; const baseUrl = await setup(withApi, { callbackOptions: { afterCallback } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -218,7 +217,7 @@ describe('callback handler', () => { expect(session).toStrictEqual({ accessTokenExpiresAt: 750, accessTokenScope: 'read:foo delete:foo', - idToken: makeIdToken({ iss: 'https://acme.auth0.local/' }), + idToken: await makeIdToken({ iss: 'https://acme.auth0.local/' }), token_type: 'Bearer', user: { nickname: '__test_nickname__', @@ -236,7 +235,7 @@ describe('callback handler', () => { }; const baseUrl = await setup(withApi, { callbackOptions: { afterCallback } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -270,7 +269,7 @@ describe('callback handler', () => { }; const baseUrl = await setup(withApi, { callbackOptions: { afterCallback } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -292,7 +291,7 @@ describe('callback handler', () => { test('throws for missing org_id claim', async () => { const baseUrl = await setup({ ...withApi, organization: 'foo' }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -314,7 +313,7 @@ describe('callback handler', () => { test('throws for org_id claim mismatch', async () => { const baseUrl = await setup({ ...withApi, organization: 'foo' }, { idTokenClaims: { org_id: 'bar' } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -341,7 +340,7 @@ describe('callback handler', () => { callbackOptions: { organization: 'foo' } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -370,7 +369,7 @@ describe('callback handler', () => { } }); const state = encodeState({ returnTo: baseUrl }); - const cookieJar = toSignedCookieJar( + const cookieJar = await toSignedCookieJar( { state, nonce: '__test_nonce__' @@ -381,14 +380,14 @@ describe('callback handler', () => { nock(`${withoutApi.issuerBaseURL}`) .post('/oauth/token', /grant_type=authorization_code/) - .reply(200, (_, body) => { + .reply(200, async (_, body) => { spy(body); return { access_token: 'eyJz93a...k4laUWw', expires_in: 750, scope: 'read:foo delete:foo', refresh_token: 'GEbRxBN...edjnXbL', - id_token: makeIdToken({ iss: `${withoutApi.issuerBaseURL}/` }), + id_token: await makeIdToken({ iss: `${withoutApi.issuerBaseURL}/` }), token_type: 'Bearer' }; }); diff --git a/tests/handlers/logout.test.ts b/tests/handlers/logout.test.ts index 8813b3d64..4a980097e 100644 --- a/tests/handlers/logout.test.ts +++ b/tests/handlers/logout.test.ts @@ -1,6 +1,7 @@ import { parse } from 'cookie'; import { parse as parseUrl, URL } from 'url'; import { withoutApi } from '../fixtures/default-settings'; +import { get } from '../auth0-session/fixtures/helpers'; import { setup, teardown, login } from '../fixtures/setup'; import { IncomingMessage } from 'http'; @@ -20,15 +21,15 @@ describe('logout handler', () => { const baseUrl = await setup(withoutApi); const cookieJar = await login(baseUrl); - const { status, headers } = await fetch(`${baseUrl}/api/auth/logout`, { - redirect: 'manual', - headers: { - cookie: cookieJar.getCookieStringSync(baseUrl) - } + const { + res: { statusCode, headers } + } = await get(baseUrl, '/api/auth/logout', { + cookieJar, + fullResponse: true }); - expect(status).toBe(302); - expect(parseUrl(headers.get('location') as string, true)).toMatchObject({ + expect(statusCode).toBe(302); + expect(parseUrl(headers['location'], true)).toMatchObject({ protocol: 'https:', host: 'acme.auth0.local', query: { @@ -46,15 +47,15 @@ describe('logout handler', () => { }); const cookieJar = await login(baseUrl); - const { status, headers } = await fetch(`${baseUrl}/api/auth/logout`, { - redirect: 'manual', - headers: { - cookie: cookieJar.getCookieStringSync(baseUrl) - } + const { + res: { statusCode, headers } + } = await get(baseUrl, '/api/auth/logout', { + cookieJar, + fullResponse: true }); - expect(status).toBe(302); - expect(parseUrl(headers.get('location') as string, true).query).toMatchObject({ + expect(statusCode).toBe(302); + expect(parseUrl(headers['location'], true).query).toMatchObject({ returnTo: 'https://www.foo.bar' }); }); @@ -65,15 +66,15 @@ describe('logout handler', () => { }); const cookieJar = await login(baseUrl); - const { status, headers } = await fetch(`${baseUrl}/api/auth/logout`, { - redirect: 'manual', - headers: { - cookie: cookieJar.getCookieStringSync(baseUrl) - } + const { + res: { statusCode, headers } + } = await get(baseUrl, '/api/auth/logout', { + cookieJar, + fullResponse: true }); - expect(status).toBe(302); - expect(parseUrl(headers.get('location') as string)).toMatchObject({ + expect(statusCode).toBe(302); + expect(parseUrl(headers['location'])).toMatchObject({ host: 'my-end-session-endpoint', pathname: '/logout' }); @@ -85,14 +86,14 @@ describe('logout handler', () => { }); const cookieJar = await login(baseUrl); - const res = await fetch(`${baseUrl}/api/auth/logout`, { - redirect: 'manual', - headers: { - cookie: cookieJar.getCookieStringSync(baseUrl) - } + const { + res: { headers } + } = await get(baseUrl, '/api/auth/logout', { + cookieJar, + fullResponse: true }); - expect(parse(res.headers.get('set-cookie') as string)).toMatchObject({ + expect(parse(headers['set-cookie'][0])).toMatchObject({ appSession: '', 'Max-Age': '0', Path: '/' diff --git a/tests/handlers/profile.test.ts b/tests/handlers/profile.test.ts index 48bbe362b..e2650848c 100644 --- a/tests/handlers/profile.test.ts +++ b/tests/handlers/profile.test.ts @@ -92,7 +92,7 @@ describe('profile handler', () => { nock(`${withoutApi.issuerBaseURL}`) .post('/oauth/token', `grant_type=refresh_token&refresh_token=GEbRxBN...edjnXbL`) .reply(200, { - id_token: makeIdToken({ iss: 'https://acme.auth0.local/' }), + id_token: await makeIdToken({ iss: 'https://acme.auth0.local/' }), token_type: 'Bearer', expires_in: 750, scope: 'read:foo write:foo' @@ -115,7 +115,7 @@ describe('profile handler', () => { }, userInfoToken: 'new-access-token' }); - refreshTokenRotationExchange(withApi, 'GEbRxBN...edjnXbL', {}, 'new-access-token', 'new-refresh-token'); + await refreshTokenRotationExchange(withApi, 'GEbRxBN...edjnXbL', {}, 'new-access-token', 'new-refresh-token'); const cookieJar = await login(baseUrl); const profile = await get(baseUrl, '/api/auth/me', { cookieJar }); expect(profile).toMatchObject({ foo: 'bar' }); diff --git a/tests/session/cache.test.ts b/tests/session/cache.test.ts index 8efb9bafe..bccc3f7cb 100644 --- a/tests/session/cache.test.ts +++ b/tests/session/cache.test.ts @@ -31,54 +31,54 @@ describe('SessionCache', () => { expect(cache).toBeInstanceOf(SessionCache); }); - test('should create the session entry', () => { - cache.create(req, res, session); - expect(cache.get(req, res)).toEqual(session); + test('should create the session entry', async () => { + await cache.create(req, res, session); + expect(await cache.get(req, res)).toEqual(session); expect(cookieStore.save).toHaveBeenCalledWith(req, res, session, undefined); }); - test('should delete the session entry', () => { - cache.create(req, res, session); - expect(cache.get(req, res)).toEqual(session); - cache.delete(req, res); - expect(cache.get(req, res)).toBeNull(); + test('should delete the session entry', async () => { + await cache.create(req, res, session); + expect(await cache.get(req, res)).toEqual(session); + await cache.delete(req, res); + expect(await cache.get(req, res)).toBeNull(); }); - test('should set authenticated for authenticated user', () => { - cache.create(req, res, session); - expect(cache.isAuthenticated(req, res)).toEqual(true); + test('should set authenticated for authenticated user', async () => { + await cache.create(req, res, session); + expect(await cache.isAuthenticated(req, res)).toEqual(true); }); - test('should set unauthenticated for anonymous user', () => { - expect(cache.isAuthenticated(req, res)).toEqual(false); + test('should set unauthenticated for anonymous user', async () => { + expect(await cache.isAuthenticated(req, res)).toEqual(false); }); - test('should get an id token for authenticated user', () => { - cache.create(req, res, session); - expect(cache.getIdToken(req, res)).toEqual('__test_id_token__'); + test('should get an id token for authenticated user', async () => { + await cache.create(req, res, session); + expect(await cache.getIdToken(req, res)).toEqual('__test_id_token__'); }); - test('should get no id token for anonymous user', () => { - expect(cache.getIdToken(req, res)).toBeUndefined(); + test('should get no id token for anonymous user', async () => { + expect(await cache.getIdToken(req, res)).toBeUndefined(); }); - test('should save the session on read and update with a rolling session', () => { - cookieStore.read = jest.fn().mockReturnValue([{ user: { sub: '__test_user__' } }, 500]); - expect(cache.isAuthenticated(req, res)).toEqual(true); - expect(cache.get(req, res)?.user).toEqual({ sub: '__test_user__' }); - cache.set(req, res, new Session({ sub: '__new_user__' })); - expect(cache.get(req, res)?.user).toEqual({ sub: '__new_user__' }); + test('should save the session on read and update with a rolling session', async () => { + cookieStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); + expect(await cache.isAuthenticated(req, res)).toEqual(true); + expect((await cache.get(req, res))?.user).toEqual({ sub: '__test_user__' }); + await cache.set(req, res, new Session({ sub: '__new_user__' })); + expect((await cache.get(req, res))?.user).toEqual({ sub: '__new_user__' }); expect(cookieStore.read).toHaveBeenCalledTimes(1); expect(cookieStore.save).toHaveBeenCalledTimes(2); }); - test('should save the session only on update without a rolling session', () => { + test('should save the session only on update without a rolling session', async () => { setup({ ...withoutApi, session: { rolling: false } }); - cookieStore.read = jest.fn().mockReturnValue([{ user: { sub: '__test_user__' } }, 500]); - expect(cache.isAuthenticated(req, res)).toEqual(true); - expect(cache.get(req, res)?.user).toEqual({ sub: '__test_user__' }); + cookieStore.read = jest.fn().mockResolvedValue([{ user: { sub: '__test_user__' } }, 500]); + expect(await cache.isAuthenticated(req, res)).toEqual(true); + expect((await cache.get(req, res))?.user).toEqual({ sub: '__test_user__' }); cache.set(req, res, new Session({ sub: '__new_user__' })); - expect(cache.get(req, res)?.user).toEqual({ sub: '__new_user__' }); + expect((await cache.get(req, res))?.user).toEqual({ sub: '__new_user__' }); expect(cookieStore.read).toHaveBeenCalledTimes(1); expect(cookieStore.save).toHaveBeenCalledTimes(1); }); diff --git a/tests/session/get-access-token.test.ts b/tests/session/get-access-token.test.ts index 05df43e71..bc49c044e 100644 --- a/tests/session/get-access-token.test.ts +++ b/tests/session/get-access-token.test.ts @@ -122,7 +122,7 @@ describe('get access token', () => { }); test('should retrieve a new access token if the old one is expired and update the profile', async () => { - refreshTokenExchange( + await refreshTokenExchange( withApi, 'GEbRxBN...edjnXbL', { @@ -148,7 +148,7 @@ describe('get access token', () => { }); test('should retrieve a new access token if force refresh is set', async () => { - refreshTokenExchange( + await refreshTokenExchange( withApi, 'GEbRxBN...edjnXbL', { @@ -167,7 +167,7 @@ describe('get access token', () => { }); test('should retrieve a new access token and rotate the refresh token', async () => { - refreshTokenRotationExchange( + await refreshTokenRotationExchange( withApi, 'GEbRxBN...edjnXbL', { @@ -194,7 +194,7 @@ describe('get access token', () => { }); test('should not overwrite custom session properties when applying a new access token', async () => { - refreshTokenExchange( + await refreshTokenExchange( withApi, 'GEbRxBN...edjnXbL', { @@ -233,7 +233,7 @@ describe('get access token', () => { }); test('should retrieve a new access token and update the session based on afterRefresh', async () => { - refreshTokenExchange( + await refreshTokenExchange( withApi, 'GEbRxBN...edjnXbL', { @@ -262,7 +262,7 @@ describe('get access token', () => { }); test('should pass custom auth params in refresh grant request body', async () => { - const idToken = makeIdToken({ + const idToken = await makeIdToken({ iss: `${withApi.issuerBaseURL}/`, aud: withApi.clientID, email: 'john@test.com', diff --git a/tests/session/session.test.ts b/tests/session/session.test.ts index faed75bbd..a62de215d 100644 --- a/tests/session/session.test.ts +++ b/tests/session/session.test.ts @@ -8,9 +8,9 @@ describe('session', () => { expect(new Session({ foo: 'bar' }).user).toEqual({ foo: 'bar' }); }); - test('should construct a session from a tokenSet', () => { + test('should construct a session from a tokenSet', async () => { expect( - fromTokenSet(new TokenSet({ id_token: makeIdToken({ foo: 'bar', bax: 'qux' }) }), { + fromTokenSet(new TokenSet({ id_token: await makeIdToken({ foo: 'bar', bax: 'qux' }) }), { identityClaimFilter: ['baz'], routes: { login: '', callback: '', postLogoutRedirect: '' } }).user diff --git a/tests/session/update-user.test.ts b/tests/session/update-user.test.ts index 0ee090350..fe8641f19 100644 --- a/tests/session/update-user.test.ts +++ b/tests/session/update-user.test.ts @@ -1,6 +1,7 @@ import { login, setup, teardown } from '../fixtures/setup'; import { withoutApi } from '../fixtures/default-settings'; import { get, post } from '../auth0-session/fixtures/helpers'; +import { CookieJar } from 'tough-cookie'; describe('update-user', () => { afterEach(teardown); @@ -27,8 +28,9 @@ describe('update-user', () => { test('should ignore updates if user is not logged in', async () => { const baseUrl = await setup(withoutApi); - await expect(get(baseUrl, '/api/auth/me')).rejects.toThrow('Unauthorized'); - await post(baseUrl, '/api/update-user', { body: { user: { sub: 'foo' } } }); - await expect(get(baseUrl, '/api/auth/me')).rejects.toThrow('Unauthorized'); + const cookieJar = new CookieJar(); + await expect(get(baseUrl, '/api/auth/me', { cookieJar })).rejects.toThrow('Unauthorized'); + await post(baseUrl, '/api/update-user', { body: { user: { sub: 'foo' } }, cookieJar }); + await expect(get(baseUrl, '/api/auth/me', { cookieJar })).rejects.toThrow('Unauthorized'); }); }); From 005c8e47ebf73ff16bb9815954ca14a758658c70 Mon Sep 17 00:00:00 2001 From: adamjmcgrath Date: Thu, 1 Sep 2022 10:49:08 +0100 Subject: [PATCH 2/4] Update logic for handling multiple secrets per PR review --- src/auth0-session/cookie-store.ts | 19 +++++++++---- src/auth0-session/transient-store.ts | 36 +++++++++++------------- src/auth0-session/utils/p-any.ts | 7 ----- src/config.ts | 3 +- tests/auth0-session/cookie-store.test.ts | 4 +-- tests/auth0-session/utils/p-any.test.ts | 27 ------------------ 6 files changed, 33 insertions(+), 63 deletions(-) delete mode 100644 src/auth0-session/utils/p-any.ts delete mode 100644 tests/auth0-session/utils/p-any.test.ts diff --git a/src/auth0-session/cookie-store.ts b/src/auth0-session/cookie-store.ts index 2b9b73412..f04a958d7 100644 --- a/src/auth0-session/cookie-store.ts +++ b/src/auth0-session/cookie-store.ts @@ -5,7 +5,6 @@ import { CookieSerializeOptions, serialize } from 'cookie'; import { encryption as deriveKey } from './utils/hkdf'; import createDebug from './utils/debug'; import Cookies from './utils/cookies'; -import pAny from './utils/p-any'; import { Config } from './config'; const debug = createDebug('cookie-store'); @@ -52,9 +51,17 @@ export default class CookieStore { return await new jose.EncryptJWT({ ...payload }).setProtectedHeader({ alg, enc, uat, iat, exp }).encrypt(key); } - private async decrypt(jwe: string): Promise { + private async decrypt(jwe: string): Promise { const keys = await this.getKeys(); - return pAny(keys.map((key) => jose.compactDecrypt(jwe, key))) as Promise; + let err; + for (let key of keys) { + try { + return await jose.jwtDecrypt(jwe, key); + } catch (e) { + err = e; + } + } + throw err; } private calculateExp(iat: number, uat: number): number { @@ -110,7 +117,7 @@ export default class CookieStore { } if (existingSessionValue) { - const { protectedHeader: header, plaintext } = await this.decrypt(existingSessionValue); + const { protectedHeader: header, payload } = await this.decrypt(existingSessionValue); ({ iat, uat, exp } = header as unknown as Header); // check that the existing session isn't expired based on options when it was established @@ -126,13 +133,13 @@ export default class CookieStore { assert(iat + absoluteDuration > epoch(), 'it is expired based on current absoluteDuration rules'); } - return [JSON.parse(new TextDecoder().decode(plaintext)), iat]; + return [payload, iat]; } } catch (err) { /* istanbul ignore else */ if (err instanceof AssertionError) { debug('existing session was rejected because', err.message); - } else if (Array.isArray(err) && err[0] instanceof jose.errors.JOSEError) { + } else if (err instanceof jose.errors.JOSEError) { debug('existing session was rejected because it could not be decrypted', err); } else { debug('unexpected error handling session', err); diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts index 5ffadb213..49df7a331 100644 --- a/src/auth0-session/transient-store.ts +++ b/src/auth0-session/transient-store.ts @@ -1,7 +1,6 @@ import { IncomingMessage, ServerResponse } from 'http'; import { generators } from 'openid-client'; import * as jose from 'jose'; -import pAny from './utils/p-any'; import { signing as deriveKey } from './utils/hkdf'; import Cookies from './utils/cookies'; import { Config } from './config'; @@ -16,26 +15,23 @@ const getCookieValue = async (k: string, v: string, keys: Uint8Array[]): Promise return undefined; } const [value, signature] = v.split('.'); - try { - const flattenedJWS = { - protected: jose.base64url.encode(JSON.stringify({ alg: 'HS256', b64: false, crit: ['b64'] })), - payload: `${k}=${value}`, - signature - }; - await pAny( - keys.map((key) => - jose.flattenedVerify(flattenedJWS, key, { - algorithms: ['HS256'], - crit: { - b64: false - } - }) - ) - ); - return value; - } catch (err) { - return; + const flattenedJWS = { + protected: jose.base64url.encode(JSON.stringify({ alg: 'HS256', b64: false, crit: ['b64'] })), + payload: `${k}=${value}`, + signature + }; + for (let key of keys) { + try { + await jose.flattenedVerify(flattenedJWS, key, { + algorithms: ['HS256'], + crit: { + b64: false + } + }); + return value; + } catch (e) {} } + return; }; export const generateCookieValue = async (cookie: string, value: string, key: Uint8Array): Promise => { diff --git a/src/auth0-session/utils/p-any.ts b/src/auth0-session/utils/p-any.ts deleted file mode 100644 index 1e416d0b0..000000000 --- a/src/auth0-session/utils/p-any.ts +++ /dev/null @@ -1,7 +0,0 @@ -const flip = (promise: Promise) => new Promise((a, b) => promise.then(b, a)); - -// Lightweight Promise.any-like implementation -// Promise.all returns the first rejected promise or all resolved promises -// Promise.any returns the first resolved promise or all rejected promises -// If we flip all the promises of Promise.all then flip back the result, we get the behaviour of Promise.any -export default async (promises: Promise[]) => flip(Promise.all(promises.map(flip))); diff --git a/src/config.ts b/src/config.ts index 6134d9002..f8b6f1855 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,8 @@ export interface BaseConfig { /** * The secret(s) used to derive an encryption key for the user identity in a session cookie and * to sign the transient cookies used by the login callback. - * Use a single string key or array of keys for an encrypted session cookie. + * Provide a single string secret, but if you want to rotate the secret you can provide an array putting + * the new secret first. * You can also use the AUTH0_SECRET environment variable. */ secret: string | Array; diff --git a/tests/auth0-session/cookie-store.test.ts b/tests/auth0-session/cookie-store.test.ts index e9b49ce2e..0098662f7 100644 --- a/tests/auth0-session/cookie-store.test.ts +++ b/tests/auth0-session/cookie-store.test.ts @@ -347,10 +347,10 @@ describe('CookieStore', () => { }); it('should not logout v1 users', async () => { - // Cookie generated with v1 cookie store tests with v long exp + // Cookie generated with v1 cookie store tests with v long absolute exp const V1_COOKIE = 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwidWF0IjoxNjYxODU5MTU3LCJpYXQiOjE2NjE4NTkxNTcsImV4cCI6MTY2MjQ2Mzk1N30..OCJcC6r7EGwTznTi.fgWVT4r9Rt8XMxooHCJcNiF5KGv7H7pg9WVygDCKfDb8qlPQQjANlKmweAoSnOTMdVweM3qZ9fvHrttlIk-kPmi3I5puMydfqihqMKRsIo9vbx24OdtUW-ZtzSqfc_nfMtX7qiu4u39FncNL2DeByTZSUUyDLf8S8V-FMakhjhPnOkweF3ztDnWaEK6w8Y_WsBJgFjFdbu8ZgKupcfKCwfxRR9xgUD9rWZ4AIuLhDId-jelRku9CqoCgL17DPbO2ytj1xs45LHL2sQGEaFLQFPZJ6bfNKdPtXQX73_nL3lqj20PqnvmzNW6DDYW0T3-kQz_VCEnBd74dtmGFwoMVbJ64Agvj55Gn_5aKFxBbdP5vb1mdKVTD7HdMfNnAPMPPyXsyvGHHaOPjnnkU8W_sNCaARS37FLWNxP59vNSvpSlN_oWCxsekHmkXVfhihaasO692eL319CPXfVa0Y3pQxUny6TunWv-HwtiV4GyrNG0ACL5gjVNS6qpcSuzOKn8NY8Y0FMnf_ISw8mz3Zel0WI_AJqU3IsGWdTHkF97ss5ckCyV0Ij9ezycbispxQ269rReUPE6Se_m5TqY7Py64MXS8ZgdG_KPrAGRP4I1KP0nLKU8NdaloI2I1HiiiDIC5hMhnmXtAvweXgOumWSACBu6PvcdGFdA-ptYaaT3vKC2-XxeVc7ynxabEeogcaXN1H_4wZ2Tjk5eLVTRTRnl0p09HBULoMr2KZAkDRjP3P-m5_Cne-1v9xGx23zzpxi3FfAH2jDBBSwEfy-GXxr65-hmIng7dOko4ul8AqWmP1f2sSYrBB-R3EZjVV0V6ssxC5I1q6Q-Xw99QsunlOYsTfikmBOvfXqNvFF1YkzsgYms6-NSSMmZmMy1huhfqfLvKuGKttqAtDlVByGQU2zF8VArYNEFc2TidtkewyzbrgWK0ygntJ17QeLMYNadNgz7eTSRwe7x-Vho_tB3XFoYPYpyA2JwIS4pb1KEdQQDevSp-_sjMbWpHnD1hruvqbCC7Zo795_N1OXt-kBbXddVsoXqzKmKJEZIPGvcJMLgeI5rLrw.c1K7B6p_vbSH9ZZrF8Uqvg'; - const baseURL = await setup(defaultConfig); + const baseURL = await setup({ ...defaultConfig, session: { rolling: false } }); const appSession = V1_COOKIE; const cookieJar = toCookieJar({ appSession }, baseURL); const session = await get(baseURL, '/session', { cookieJar }); diff --git a/tests/auth0-session/utils/p-any.test.ts b/tests/auth0-session/utils/p-any.test.ts deleted file mode 100644 index 6467e1325..000000000 --- a/tests/auth0-session/utils/p-any.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import pAny from '../../../src/auth0-session/utils/p-any'; - -const delay = (ms: number, { value }: { value: number }) => - new Promise((resolve) => setTimeout(() => resolve(value), ms)); - -describe('p-any', () => { - it('returns the first fulfilled value', async () => { - const spy = jest.fn(); - const fixture = [ - Promise.reject(new Error('1')), - Promise.resolve(2), - Promise.reject(new Error('3')).finally(spy), - Promise.resolve(4) - ]; - await expect(pAny(fixture)).resolves.toEqual(2); - }); - - it('returns the first fulfilled value #2', async () => { - const fixture = [delay(100, { value: 1 }), delay(10, { value: 2 }), delay(50, { value: 3 })]; - await expect(pAny(fixture)).resolves.toEqual(2); - }); - - it('rejects with errors', async () => { - const fixture = [Promise.reject(new Error('1')), Promise.reject(new Error('2')), Promise.reject(new Error('3'))]; - await expect(pAny(fixture)).rejects.toEqual(['1', '2', '3'].map(Error)); - }); -}); From 5aa49c391921c3bb30cb208298c7cc82e96b2047 Mon Sep 17 00:00:00 2001 From: adamjmcgrath Date: Thu, 1 Sep 2022 10:56:09 +0100 Subject: [PATCH 3/4] Remove unnecessary option --- src/auth0-session/transient-store.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/auth0-session/transient-store.ts b/src/auth0-session/transient-store.ts index 49df7a331..480437bd2 100644 --- a/src/auth0-session/transient-store.ts +++ b/src/auth0-session/transient-store.ts @@ -23,10 +23,7 @@ const getCookieValue = async (k: string, v: string, keys: Uint8Array[]): Promise for (let key of keys) { try { await jose.flattenedVerify(flattenedJWS, key, { - algorithms: ['HS256'], - crit: { - b64: false - } + algorithms: ['HS256'] }); return value; } catch (e) {} From 8bae1a185b894bcfd13172c1963228753fa83324 Mon Sep 17 00:00:00 2001 From: adamjmcgrath Date: Thu, 1 Sep 2022 11:17:15 +0100 Subject: [PATCH 4/4] upgrade jose to latest patch --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ebc64485a..5087b454c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "debug": "^4.3.4", "http-errors": "^1.8.1", "joi": "^17.6.0", - "jose": "^4.9.0", + "jose": "^4.9.2", "openid-client": "^4.9.1", "tslib": "^2.4.0", "url-join": "^4.0.1" @@ -9214,9 +9214,9 @@ } }, "node_modules/jose": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.0.tgz", - "integrity": "sha512-RgaqEOZLkVO+ViN3KkN44XJt9g7+wMveUv59sVLaTxONcUPc8ZpfqOCeLphVBZyih2dgkvZ0Ap1CNcokvY7Uyw==", + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.2.tgz", + "integrity": "sha512-EqKvu2PqJCD3Jrg3PvcYZVS7D21qMVLSYMDAFcOdGUEOpJSLNtJO7NjLANvu3SYHVl6pdP2ff7ve6EZW2nX7Nw==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -20466,9 +20466,9 @@ } }, "jose": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.0.tgz", - "integrity": "sha512-RgaqEOZLkVO+ViN3KkN44XJt9g7+wMveUv59sVLaTxONcUPc8ZpfqOCeLphVBZyih2dgkvZ0Ap1CNcokvY7Uyw==" + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.9.2.tgz", + "integrity": "sha512-EqKvu2PqJCD3Jrg3PvcYZVS7D21qMVLSYMDAFcOdGUEOpJSLNtJO7NjLANvu3SYHVl6pdP2ff7ve6EZW2nX7Nw==" }, "js-tokens": { "version": "4.0.0", diff --git a/package.json b/package.json index 3d142da77..72094b0bc 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "debug": "^4.3.4", "http-errors": "^1.8.1", "joi": "^17.6.0", - "jose": "^4.9.0", + "jose": "^4.9.2", "openid-client": "^4.9.1", "tslib": "^2.4.0", "url-join": "^4.0.1"