Repository: auth0/auth0-spa-js
Pull request #248 (2019-10-17 23:54:00)

Improved OIDC compliance
### Description

This update improves the SDK support for OpenID Connect. In particular, it modifies the sign in verification phase by substituting backchannel based checks with id_token validation.

### Testing

- [x] This change adds test coverage for new/changed/fixed functionality


Patch:
diff --git a/package-lock.json b/package-lock.json
index 305ab7e..bcc1397 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5178,12 +5178,6 @@
             "wrap-ansi": "^5.1.0"
           }
         },
-        "emoji-regex": {
-          "version": "7.0.3",
-          "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
-          "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
-          "dev": true
-        },
         "find-up": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
@@ -5949,12 +5943,6 @@
             "wrap-ansi": "^5.1.0"
           }
         },
-        "emoji-regex": {
-          "version": "7.0.3",
-          "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
-          "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
-          "dev": true
-        },
         "find-up": {
           "version": "3.0.0",
           "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
@@ -9876,6 +9864,12 @@
           "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz",
           "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==",
           "dev": true
+        },
+        "typescript": {
+          "version": "3.5.3",
+          "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz",
+          "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==",
+          "dev": true
         }
       }
     },
@@ -9892,15 +9886,15 @@
       }
     },
     "typescript": {
-      "version": "3.5.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz",
-      "integrity": "sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g==",
+      "version": "3.6.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
+      "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==",
       "dev": true
     },
     "uglify-js": {
-      "version": "3.6.1",
-      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.1.tgz",
-      "integrity": "sha512-+dSJLJpXBb6oMHP+Yvw8hUgElz4gLTh82XuX68QiJVTXaE5ibl6buzhNkQdYhBlIhozWOC9ge16wyRmjG4TwVQ==",
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.2.tgz",
+      "integrity": "sha512-+gh/xFte41GPrgSMJ/oJVq15zYmqr74pY9VoM69UzMzq9NFk4YDylclb1/bhEzZSaUQjbW5RvniHeq1cdtRYjw==",
       "dev": true,
       "optional": true,
       "requires": {
diff --git a/package.json b/package.json
index 8905941..a53ca98 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
     "gzip-size": "^5.1.1",
     "husky": "^3.0.0",
     "idtoken-verifier": "^1.4.1",
-    "jest": "^24.8.0",
+    "jest": "^24.9.0",
     "jsonwebtoken": "^8.5.1",
     "pem": "^1.14.2",
     "prettier": "^1.18.2",
@@ -65,7 +65,7 @@
     "tslint": "^5.18.0",
     "tslint-config-security": "^1.16.0",
     "typedoc": "^0.15.0",
-    "typescript": "^3.5.3",
+    "typescript": "^3.6.4",
     "wait-on": "^3.3.0"
   },
   "dependencies": {
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index c009e1c..27ec289 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -88,7 +88,8 @@ export default class Auth0Client {
       aud: this.options.client_id,
       id_token,
       nonce,
-      leeway: this.options.leeway
+      leeway: this.options.leeway,
+      max_age: this.options.max_age
     });
   }
 
diff --git a/src/global.ts b/src/global.ts
index f2bc7a1..4c0e5dc 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -240,6 +240,7 @@ interface JWTVerifyOptions {
   id_token: string;
   nonce?: string;
   leeway?: number;
+  max_age?: string;
 }
 
 /**
diff --git a/src/jwt.ts b/src/jwt.ts
index 1cce8fd..af249ca 100644
--- a/src/jwt.ts
+++ b/src/jwt.ts
@@ -1,5 +1,7 @@
 import { urlDecodeB64 } from './utils';
 
+const isNumber = n => typeof n === 'number';
+
 const idTokendecoded = [
   'iss',
   'aud',
@@ -34,10 +36,15 @@ const idTokendecoded = [
 ];
 
 export const decode = (token: string) => {
-  const [header, payload, signature] = token.split('.');
+  const parts = token.split('.');
+  const [header, payload, signature] = parts;
+
+  if (parts.length !== 3 || !header || !payload || !signature) {
+    throw new Error('ID token could not be decoded');
+  }
   const payloadJSON = JSON.parse(urlDecodeB64(payload));
   const claims: IdToken = { __raw: token };
-  const user = {};
+  const user: any = {};
   Object.keys(payloadJSON).forEach(k => {
     claims[k] = payloadJSON[k];
     if (!idTokendecoded.includes(k)) {
@@ -53,37 +60,136 @@ export const decode = (token: string) => {
 };
 
 export const verify = (options: JWTVerifyOptions) => {
+  if (!options.id_token) {
+    throw new Error('ID token is required but missing');
+  }
+
   const decoded = decode(options.id_token);
 
+  if (!decoded.claims.iss) {
+    throw new Error(
+      'Issuer (iss) claim must be a string present in the ID token'
+    );
+  }
+
   if (decoded.claims.iss !== options.iss) {
-    throw new Error('Invalid issuer');
+    throw new Error(
+      `Issuer (iss) claim mismatch in the ID token; expected "${options.iss}", found "${decoded.claims.iss}"`
+    );
   }
-  if (decoded.claims.aud !== options.aud) {
-    throw new Error('Invalid audience');
+
+  if (!decoded.user.sub) {
+    throw new Error(
+      'Subject (sub) claim must be a string present in the ID token'
+    );
   }
+
   if (decoded.header.alg !== 'RS256') {
-    throw new Error('Invalid algorithm');
+    throw new Error(
+      `Signature algorithm of "${decoded.header.alg}" is not supported. Expected the ID token to be signed with "RS256".`
+    );
+  }
+
+  if (
+    !decoded.claims.aud ||
+    !(
+      typeof decoded.claims.aud === 'string' ||
+      Array.isArray(decoded.claims.aud)
+    )
+  ) {
+    throw new Error(
+      'Audience (aud) claim must be a string or array of strings present in the ID token'
+    );
   }
-  if (decoded.claims.nonce !== options.nonce) {
-    throw new Error('Invalid nonce');
+  if (Array.isArray(decoded.claims.aud)) {
+    if (!decoded.claims.aud.includes(options.aud)) {
+      throw new Error(
+        `Audience (aud) claim mismatch in the ID token; expected "${
+          options.aud
+        }" but was not one of "${decoded.claims.aud.join(', ')}"`
+      );
+    }
+    if (decoded.claims.aud.length > 1) {
+      if (!decoded.claims.azp) {
+        throw new Error(
+          'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'
+        );
+      }
+      if (decoded.claims.azp !== options.aud) {
+        throw new Error(
+          `Authorized Party (azp) claim mismatch in the ID token; expected "${options.aud}", found "${decoded.claims.azp}"`
+        );
+      }
+    }
+  } else if (decoded.claims.aud !== options.aud) {
+    throw new Error(
+      `Audience (aud) claim mismatch in the ID token; expected "${options.aud}" but found "${decoded.claims.aud}"`
+    );
+  }
+  if (options.nonce) {
+    if (!decoded.claims.nonce) {
+      throw new Error(
+        'Nonce (nonce) claim must be a string present in the ID token'
+      );
+    }
+    if (decoded.claims.nonce !== options.nonce) {
+      throw new Error(
+        `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${decoded.claims.nonce}"`
+      );
+    }
+  }
+
+  if (options.max_age && !isNumber(decoded.claims.auth_time)) {
+    throw new Error(
+      'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
+    );
+  }
+
+  /* istanbul ignore next */
+  if (!isNumber(decoded.claims.exp)) {
+    throw new Error(
+      'Expiration Time (exp) claim must be a number present in the ID token'
+    );
+  }
+  if (!isNumber(decoded.claims.iat)) {
+    throw new Error(
+      'Issued At (iat) claim must be a number present in the ID token'
+    );
   }
 
+  const leeway = options.leeway || 60;
   const now = new Date();
   const expDate = new Date(0);
   const iatDate = new Date(0);
   const nbfDate = new Date(0);
-  const leeway = options.leeway || 60;
+  const authTimeDate = new Date(0);
+  authTimeDate.setUTCSeconds(
+    (parseInt(decoded.claims.auth_time) + parseInt(options.max_age)) / 1000 +
+      leeway
+  );
   expDate.setUTCSeconds(decoded.claims.exp + leeway);
   iatDate.setUTCSeconds(decoded.claims.iat - leeway);
   nbfDate.setUTCSeconds(decoded.claims.nbf - leeway);
+
   if (now > expDate) {
-    throw new Error('id_token expired');
+    throw new Error(
+      `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expDate})`
+    );
   }
   if (now < iatDate) {
-    throw new Error('id_token was issued in the future (invalid iat)');
+    throw new Error(
+      `Issued At (iat) claim error in the ID token; current time (${now}) is before issued at time (${iatDate})`
+    );
+  }
+  if (isNumber(decoded.claims.nbf) && now < nbfDate) {
+    throw new Error(
+      `Not Before time (nbf) claim in the ID token indicates that this token can't be used just yet. Currrent time (${now}) is before ${nbfDate}`
+    );
   }
-  if (typeof decoded.claims.nbf !== 'undefined' && now < nbfDate) {
-    throw new Error('token is not yet valid (invalid notBefore)');
+  if (isNumber(decoded.claims.auth_time) && now > authTimeDate) {
+    throw new Error(
+      `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Currrent time (${now}) is after last auth at ${authTimeDate}`
+    );
   }
   return decoded;
 };


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index bf15c09..e5a8ab8 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -283,7 +283,9 @@ describe('Auth0', () => {
         id_token: TEST_ID_TOKEN,
         nonce: TEST_RANDOM_STRING,
         aud: 'test-client-id',
-        iss: 'https://test.auth0.com/'
+        iss: 'https://test.auth0.com/',
+        leeway: undefined,
+        max_age: undefined
       });
     });
     it('calls `tokenVerifier.verify` with the `issuer` from in the oauth/token response', async () => {
@@ -296,7 +298,9 @@ describe('Auth0', () => {
         aud: 'test-client-id',
         id_token: TEST_ID_TOKEN,
         nonce: TEST_RANDOM_STRING,
-        iss: 'https://test-123.auth0.com/'
+        iss: 'https://test-123.auth0.com/',
+        leeway: undefined,
+        max_age: undefined
       });
     });
     it('calls `tokenVerifier.verify` with the `leeway` from constructor', async () => {
@@ -308,7 +312,21 @@ describe('Auth0', () => {
         nonce: TEST_RANDOM_STRING,
         aud: 'test-client-id',
         iss: 'https://test.auth0.com/',
-        leeway: 10
+        leeway: 10,
+        max_age: undefined
+      });
+    });
+    it('calls `tokenVerifier.verify` with the `max_age` from constructor', async () => {
+      const { auth0, tokenVerifier } = await setup({ max_age: '10' });
+
+      await auth0.loginWithPopup({});
+      expect(tokenVerifier).toHaveBeenCalledWith({
+        id_token: TEST_ID_TOKEN,
+        nonce: TEST_RANDOM_STRING,
+        aud: 'test-client-id',
+        iss: 'https://test.auth0.com/',
+        leeway: undefined,
+        max_age: '10'
       });
     });
     it('saves cache', async () => {
@@ -616,7 +634,9 @@ describe('Auth0', () => {
           id_token: TEST_ID_TOKEN,
           nonce: TEST_RANDOM_STRING,
           aud: 'test-client-id',
-          iss: 'https://test.auth0.com/'
+          iss: 'https://test.auth0.com/',
+          leeway: undefined,
+          max_age: undefined
         });
       });
       it('saves cache', async () => {
@@ -773,7 +793,9 @@ describe('Auth0', () => {
           id_token: TEST_ID_TOKEN,
           nonce: TEST_RANDOM_STRING,
           aud: 'test-client-id',
-          iss: 'https://test.auth0.com/'
+          iss: 'https://test.auth0.com/',
+          leeway: undefined,
+          max_age: undefined
         });
       });
       it('saves cache', async () => {
@@ -1118,7 +1140,9 @@ describe('Auth0', () => {
           id_token: TEST_ID_TOKEN,
           nonce: TEST_RANDOM_STRING,
           aud: 'test-client-id',
-          iss: 'https://test.auth0.com/'
+          iss: 'https://test.auth0.com/',
+          leeway: undefined,
+          max_age: undefined
         });
       });
       it('saves cache', async () => {
diff --git a/__tests__/jwt.test.ts b/__tests__/jwt.test.ts
index 04fae17..666ca65 100644
--- a/__tests__/jwt.test.ts
+++ b/__tests__/jwt.test.ts
@@ -11,7 +11,8 @@ interface Certificate {
 const verifyOptions = {
   iss: 'https://brucke.auth0.com/',
   aud: 'k5u3o2fiAA8XweXEEX604KCwCjzjtMU6',
-  nonce: 'omcw.ptjx3~.8VBm3OuMziLdn5PB0uXG'
+  nonce: 'omcw.ptjx3~.8VBm3OuMziLdn5PB0uXG',
+  client_id: 'the_client_id'
 };
 
 const createCertificate = (): Promise<Certificate> =>
@@ -33,7 +34,12 @@ const createCertificate = (): Promise<Certificate> =>
     });
   });
 
-const DEFAULT_PAYLOAD = <any>{ payload: true, nonce: verifyOptions.nonce };
+const DEFAULT_PAYLOAD = <any>{
+  sub: 'id|123',
+  payload: true,
+  nonce: verifyOptions.nonce,
+  azp: verifyOptions.aud
+};
 const createJWT = async (payload = DEFAULT_PAYLOAD, options = {}) => {
   const cert = await createCertificate();
   return jwt.sign(payload, cert.serviceKey, {
@@ -82,6 +88,26 @@ describe('jwt', async () => {
       }
     });
   });
+  describe('validates id_token', () => {
+    const IDTOKEN_ERROR_MESSAGE = 'ID token could not be decoded';
+    it('throws when there is less than 3 parts', () => {
+      expect(() => decode('test')).toThrow(IDTOKEN_ERROR_MESSAGE);
+      expect(() => decode('test.')).toThrow(IDTOKEN_ERROR_MESSAGE);
+      expect(() => decode('test.test')).toThrow(IDTOKEN_ERROR_MESSAGE);
+      expect(() => decode('test.test.test.test')).toThrow(
+        IDTOKEN_ERROR_MESSAGE
+      );
+    });
+    it('throws when there is no header', () => {
+      expect(() => decode('.test.test')).toThrow(IDTOKEN_ERROR_MESSAGE);
+    });
+    it('throws when there is no payload', () => {
+      expect(() => decode('test..test')).toThrow(IDTOKEN_ERROR_MESSAGE);
+    });
+    it('throws when there is no signature', () => {
+      expect(() => decode('test.test.')).toThrow(IDTOKEN_ERROR_MESSAGE);
+    });
+  });
   it('verifies correctly', async done => {
     const id_token = await createJWT();
     const { encoded, header, claims } = verify({
@@ -97,72 +123,183 @@ describe('jwt', async () => {
       done();
     });
   });
+  it('verifies correctly with multiple audiences and azp', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      audience: ['item 1', verifyOptions.aud]
+    });
+
+    const { encoded, header, claims } = verify({
+      ...verifyOptions,
+      id_token
+    });
+    expect({ encoded, header, payload: claims }).toMatchObject(
+      verifier.decode(id_token)
+    );
+  });
+  it('validates id_token is present', async () => {
+    expect(() => verify({ ...verifyOptions, id_token: '' })).toThrow(
+      'ID token is required but missing'
+    );
+  });
+
+  it('validates algorithm', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      algorithm: 'HS256'
+    });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      `Signature algorithm of "HS256" is not supported. Expected the ID token to be signed with "RS256".`
+    );
+  });
+  it('validates issuer is present', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, { issuer: '' });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      'Issuer (iss) claim must be a string present in the ID token'
+    );
+  });
   it('validates issuer', async () => {
     const id_token = await createJWT();
     expect(() => verify({ ...verifyOptions, id_token, iss: 'wrong' })).toThrow(
-      'Invalid issuer'
+      `Issuer (iss) claim mismatch in the ID token; expected "wrong", found "${verifyOptions.iss}"`
+    );
+  });
+  it('validates `sub` is present', async () => {
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD, sub: undefined });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      'Subject (sub) claim must be a string present in the ID token'
+    );
+  });
+  it('validates aud is present', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, { audience: '' });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      'Audience (aud) claim must be a string or array of strings present in the ID token'
     );
   });
-  it('validates audience', async () => {
+  it('validates audience with `aud` is an array', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      audience: ['client_id']
+    });
+    expect(() => verify({ ...verifyOptions, id_token, aud: 'wrong' })).toThrow(
+      `Audience (aud) claim mismatch in the ID token; expected "wrong" but was not one of "client_id"`
+    );
+  });
+  it('validates audience with `aud` is a string', async () => {
     const id_token = await createJWT();
     expect(() => verify({ ...verifyOptions, id_token, aud: 'wrong' })).toThrow(
-      'Invalid audience'
+      `Audience (aud) claim mismatch in the ID token; expected "wrong" but found "${verifyOptions.aud}"`
     );
   });
-  it('validates algorithm', async () => {
-    const id_token = await createJWT(
-      {
-        nonce: verifyOptions.nonce
-      },
-      {
-        algorithm: 'HS256'
-      }
+  it('validates exp', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      expiresIn: '-1h'
+    });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      `Expiration Time (exp) claim error in the ID token`
     );
+  });
+  it('validates nbf', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      notBefore: '1h'
+    });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      `Not Before time (nbf) claim in the ID token indicates that this token can't be used just yet.`
+    );
+  });
+  it('validates iat is present', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, { noTimestamp: true });
     expect(() => verify({ ...verifyOptions, id_token })).toThrow(
-      'Invalid algorithm'
+      'Issued At (iat) claim must be a number present in the ID token'
+    );
+  });
+  it('validates iat', async () => {
+    const tomorrow = new Date();
+    tomorrow.setDate(tomorrow.getDate() + 1);
+    const id_token = await createJWT({
+      ...DEFAULT_PAYLOAD,
+      iat: tomorrow.getTime()
+    });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      'Issued At (iat) claim error in the ID token'
+    );
+  });
+  it('does not validate nonce is present when options.nonce is undefined', async () => {
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD, nonce: undefined });
+    expect(() =>
+      verify({ ...verifyOptions, nonce: undefined, id_token })
+    ).not.toThrow();
+  });
+  it('validates nonce is present', async () => {
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD, nonce: undefined });
+    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
+      'Nonce (nonce) claim must be a string present in the ID token'
     );
   });
   it('validates nonce', async () => {
     const id_token = await createJWT();
     expect(() =>
       verify({ ...verifyOptions, id_token, nonce: 'wrong' })
-    ).toThrow('Invalid nonce');
+    ).toThrow(
+      `Nonce (nonce) claim mismatch in the ID token; expected "wrong", found "${verifyOptions.nonce}"`
+    );
   });
-  it('validates exp', async () => {
+  it('does not validate azp is present when `aud` is a string', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      audience: 'aud'
+    });
+    expect(() =>
+      verify({ ...verifyOptions, id_token, aud: 'aud' })
+    ).not.toThrow();
+  });
+  it('does not validate azp is present when `aud` is an array with a single item', async () => {
+    const id_token = await createJWT(DEFAULT_PAYLOAD, {
+      audience: ['item 1']
+    });
+    expect(() =>
+      verify({ ...verifyOptions, id_token, aud: 'item 1' })
+    ).not.toThrow();
+  });
+  it('validates azp is present when `aud` is an array with more than one item', async () => {
     const id_token = await createJWT(
+      { ...DEFAULT_PAYLOAD, azp: undefined },
       {
-        nonce: verifyOptions.nonce
-      },
-      {
-        expiresIn: '-1h'
+        audience: ['item 1', 'other_value']
       }
     );
-    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
-      'id_token expired'
+    expect(() =>
+      verify({ ...verifyOptions, id_token, aud: 'other_value' })
+    ).toThrow(
+      'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'
     );
   });
-  it('validates nbf', async () => {
+  it('validates azp when `aud` is an array with more than one item', async () => {
     const id_token = await createJWT(
+      { ...DEFAULT_PAYLOAD, azp: 'not_the_client_id' },
       {
-        nonce: verifyOptions.nonce
-      },
-      {
-        notBefore: '1h'
+        audience: ['item 1', 'other_value']
       }
     );
-    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
-      'token is not yet valid (invalid notBefore)'
+    expect(() =>
+      verify({ ...verifyOptions, id_token, aud: 'other_value' })
+    ).toThrow(
+      `Authorized Party (azp) claim mismatch in the ID token; expected "other_value", found "not_the_client_id"`
     );
   });
-  it('validates iat', async () => {
-    const tomorrow = new Date();
-    tomorrow.setDate(tomorrow.getDate() + 1);
+  it('validate auth_time is present when max_age is provided', async () => {
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD });
+    expect(() =>
+      verify({ ...verifyOptions, id_token, max_age: '123' })
+    ).toThrow(
+      'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
+    );
+  });
+  it('validate auth_time + max_age is in the future', async () => {
+    const yesterday = new Date();
+    yesterday.setDate(yesterday.getDate() - 1);
     const id_token = await createJWT({
-      nonce: verifyOptions.nonce,
-      iat: tomorrow.getTime()
+      ...DEFAULT_PAYLOAD,
+      auth_time: yesterday.getTime()
     });
-    expect(() => verify({ ...verifyOptions, id_token })).toThrow(
-      'id_token was issued in the future (invalid iat)'
+    expect(() => verify({ ...verifyOptions, id_token, max_age: '1' })).toThrow(
+      'Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication.'
     );
   });
 });