Repository: auth0/auth0-spa-js
Pull request #631 (2020-11-06 16:47:39)

[SDK-2121] Add support for token validation for Organizations
This PR adds support for validating the `org_id` claim in the ID token, if the
user logs into the app in the context of an Organization.

This PR does not add documentation or an API for passing `organization` to the
authorization endpoint, but it is possible to do it as you can already pass
arbitrary parameters.

**Note:** This feature is not yet available to the general public.

## Testing

This PR adds new unit tests to cover the validation of the `org_id` claim, as
well as storing the `organization` value in the transaction, if given.



Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index bf1d794..5ac7dda 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -248,12 +248,17 @@ export default class Auth0Client {
   private _authorizeUrl(authorizeOptions: AuthorizeOptions) {
     return this._url(`/authorize?${createQueryParams(authorizeOptions)}`);
   }
-  private _verifyIdToken(id_token: string, nonce?: string) {
+  private _verifyIdToken(
+    id_token: string,
+    nonce?: string,
+    organizationId?: string
+  ) {
     return verifyIdToken({
       iss: this.tokenIssuer,
       aud: this.options.client_id,
       id_token,
       nonce,
+      organizationId,
       leeway: this.options.leeway,
       max_age: this._parseNumber(this.options.max_age)
     });
@@ -298,6 +303,7 @@ export default class Auth0Client {
     );
 
     const url = this._authorizeUrl(params);
+    const organizationId = options.organization || this.options.organization;
 
     this.transactionManager.create({
       nonce: nonceIn,
@@ -305,7 +311,8 @@ export default class Auth0Client {
       appState,
       scope: params.scope,
       audience: params.audience || 'default',
-      redirect_uri: params.redirect_uri
+      redirect_uri: params.redirect_uri,
+      ...(organizationId && { organizationId })
     });
 
     return url + fragment;
@@ -378,7 +385,13 @@ export default class Auth0Client {
       this.worker
     );
 
-    const decodedToken = this._verifyIdToken(authResult.id_token, nonceIn);
+    const organizationId = options.organization || this.options.organization;
+
+    const decodedToken = this._verifyIdToken(
+      authResult.id_token,
+      nonceIn,
+      organizationId
+    );
 
     const cacheEntry = {
       ...authResult,
@@ -482,9 +495,9 @@ export default class Auth0Client {
       throw new Error('Invalid state');
     }
 
-    if (error) {
-      this.transactionManager.remove();
+    this.transactionManager.remove();
 
+    if (error) {
       throw new AuthenticationError(
         error,
         error_description,
@@ -493,8 +506,6 @@ export default class Auth0Client {
       );
     }
 
-    this.transactionManager.remove();
-
     const tokenOptions = {
       audience: transaction.audience,
       scope: transaction.scope,
@@ -515,7 +526,8 @@ export default class Auth0Client {
 
     const decodedToken = this._verifyIdToken(
       authResult.id_token,
-      transaction.nonce
+      transaction.nonce,
+      transaction.organizationId
     );
 
     const cacheEntry = {
diff --git a/src/global.ts b/src/global.ts
index 36f1c8f..ab91f90 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -422,6 +422,7 @@ export interface JWTVerifyOptions {
   nonce?: string;
   leeway?: number;
   max_age?: number;
+  organizationId?: string;
 }
 
 /**
@@ -464,5 +465,6 @@ export interface IdToken {
   sub_jwk?: string;
   cnf?: string;
   sid?: string;
+  org_id?: string;
   [key: string]: any;
 }
diff --git a/src/jwt.ts b/src/jwt.ts
index 61768b8..9ef9832 100644
--- a/src/jwt.ts
+++ b/src/jwt.ts
@@ -176,15 +176,30 @@ export const verify = (options: JWTVerifyOptions) => {
       `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expDate})`
     );
   }
+
   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 (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}`
     );
   }
+
+  if (options.organizationId) {
+    if (!decoded.claims.org_id) {
+      throw new Error(
+        'Organization ID (org_id) claim must be a string present in the ID token'
+      );
+    } else if (options.organizationId !== decoded.claims.org_id) {
+      throw new Error(
+        `Organization ID (org_id) claim mismatch in the ID token; expected "${options.organizationId}", found "${decoded.claims.org_id}"`
+      );
+    }
+  }
+
   return decoded;
 };
diff --git a/src/transaction-manager.ts b/src/transaction-manager.ts
index e025334..be45f67 100644
--- a/src/transaction-manager.ts
+++ b/src/transaction-manager.ts
@@ -9,6 +9,7 @@ interface Transaction {
   appState?: any;
   code_verifier: string;
   redirect_uri: string;
+  organizationId?: string;
 }
 
 export default class TransactionManager {


Test patch:
diff --git a/__tests__/Auth0Client.test.ts b/__tests__/Auth0Client.test.ts
index e5efa2d..b73631c 100644
--- a/__tests__/Auth0Client.test.ts
+++ b/__tests__/Auth0Client.test.ts
@@ -452,6 +452,29 @@ describe('Auth0Client', () => {
       );
     });
 
+    it('calls `tokenVerifier.verify` with the organization id', async () => {
+      const auth0 = setup({ organization: 'test_org_123' });
+
+      await loginWithPopup(auth0);
+
+      expect(tokenVerifier).toHaveBeenCalledWith(
+        expect.objectContaining({
+          organizationId: 'test_org_123'
+        })
+      );
+    });
+
+    it('calls `tokenVerifier.verify` with the organization id given in the login method', async () => {
+      const auth0 = setup();
+      await loginWithPopup(auth0, { organization: 'test_org_123' });
+
+      expect(tokenVerifier).toHaveBeenCalledWith(
+        expect.objectContaining({
+          organizationId: 'test_org_123'
+        })
+      );
+    });
+
     it('saves into cache', async () => {
       const auth0 = setup();
 
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index beff302..97d388f 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -53,6 +53,8 @@ const TEST_REFRESH_TOKEN = 'refresh-token';
 const TEST_USER_ID = 'user-id';
 const TEST_USER_EMAIL = 'user@email.com';
 const TEST_APP_STATE = { bestPet: 'dog' };
+const TEST_ORG_ID = 'org_id_123';
+
 const TEST_AUTH0_CLIENT_QUERY_STRING = `&auth0Client=${encodeURIComponent(
   btoa(
     JSON.stringify({
@@ -446,6 +448,45 @@ describe('Auth0', () => {
       });
     });
 
+    it('calls `transactionManager.create` and stores the organization ID if specified in the options', async () => {
+      const { auth0, transactionManager } = await setup();
+
+      await auth0.buildAuthorizeUrl({
+        ...REDIRECT_OPTIONS,
+        organization: TEST_ORG_ID
+      });
+
+      expect(transactionManager.create).toHaveBeenCalledWith({
+        appState: TEST_APP_STATE,
+        audience: 'default',
+        code_verifier: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
+        scope: TEST_SCOPES,
+        redirect_uri: 'https://redirect.uri',
+        organizationId: TEST_ORG_ID
+      });
+    });
+
+    it('calls `transactionManager.create` and stores the organization ID if specified in the constructor', async () => {
+      const { auth0, transactionManager } = await setup({
+        organization: TEST_ORG_ID
+      });
+
+      await auth0.buildAuthorizeUrl({
+        ...REDIRECT_OPTIONS
+      });
+
+      expect(transactionManager.create).toHaveBeenCalledWith({
+        appState: TEST_APP_STATE,
+        audience: 'default',
+        code_verifier: TEST_RANDOM_STRING,
+        nonce: TEST_ENCODED_STATE,
+        scope: TEST_SCOPES,
+        redirect_uri: 'https://redirect.uri',
+        organizationId: TEST_ORG_ID
+      });
+    });
+
     it('returns the url', async () => {
       const { auth0 } = await setup();
 
@@ -636,6 +677,7 @@ describe('Auth0', () => {
           undefined
         );
       });
+
       it('calls oauth/token with redirect uri from transaction if set', async () => {
         const { auth0, utils, transactionManager } = await localSetup();
         const txn = transactionManager.get.mockReturnValue({
@@ -651,6 +693,7 @@ describe('Auth0', () => {
         expect(arg.hasOwnProperty('redirect_uri')).toBeTruthy();
         expect(arg.redirect_uri).toEqual('http://localhost');
       });
+
       it('calls oauth/token without redirect uri if not set in transaction', async () => {
         const { auth0, utils, transactionManager } = await localSetup();
         const txn = transactionManager.get.mockReturnValue({
@@ -664,6 +707,7 @@ describe('Auth0', () => {
         const arg = utils.oauthToken.mock.calls[0][0];
         expect(arg.hasOwnProperty('redirect_uri')).toBeFalsy();
       });
+
       it('calls `tokenVerifier.verify` with the `id_token` from in the oauth/token response', async () => {
         const { auth0, tokenVerifier } = await localSetup();
 
@@ -678,6 +722,32 @@ describe('Auth0', () => {
           max_age: undefined
         });
       });
+
+      it('calls `tokenVerifier.verify` with the organization ID from in the transaction, if set', async () => {
+        const { auth0, tokenVerifier, transactionManager } = await localSetup();
+
+        transactionManager.get.mockReturnValue({
+          code_verifier: TEST_RANDOM_STRING,
+          nonce: TEST_RANDOM_STRING,
+          audience: 'default',
+          scope: TEST_SCOPES,
+          redirect_uri: 'http://localhost',
+          organizationId: TEST_ORG_ID
+        });
+
+        await auth0.handleRedirectCallback();
+
+        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: undefined,
+          organizationId: TEST_ORG_ID
+        });
+      });
+
       it('saves cache', async () => {
         const { auth0, cache } = await localSetup();
 
diff --git a/__tests__/jwt.test.ts b/__tests__/jwt.test.ts
index 16d3f91..7e414be 100644
--- a/__tests__/jwt.test.ts
+++ b/__tests__/jwt.test.ts
@@ -138,6 +138,7 @@ describe('jwt', () => {
       done();
     });
   });
+
   it('verifies correctly with multiple audiences and azp', async () => {
     const id_token = await createJWT(DEFAULT_PAYLOAD, {
       audience: ['item 1', verifyOptions.aud]
@@ -151,6 +152,23 @@ describe('jwt', () => {
       verifier.decode(id_token)
     );
   });
+
+  it('verifies correctly with an organization ID', async () => {
+    const org_id = 'test_org_123';
+
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD, org_id });
+
+    const { encoded, header, claims } = verify({
+      ...verifyOptions,
+      id_token,
+      organizationId: org_id
+    });
+
+    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'
@@ -317,4 +335,27 @@ describe('jwt', () => {
       )}) is after last auth at ${authTimeDateCorrected}`
     );
   });
+
+  it('validate org_id is present when organizationId is provided', async () => {
+    const id_token = await createJWT({ ...DEFAULT_PAYLOAD });
+
+    expect(() =>
+      verify({ ...verifyOptions, id_token, organizationId: 'test_org_123' })
+    ).toThrow(
+      'Organization ID (org_id) claim must be a string present in the ID token'
+    );
+  });
+
+  it('validate org_id matches the claim when organizationId is provided', async () => {
+    const id_token = await createJWT({
+      ...DEFAULT_PAYLOAD,
+      org_id: 'test_org_456'
+    });
+
+    expect(() =>
+      verify({ ...verifyOptions, id_token, organizationId: 'test_org_123' })
+    ).toThrow(
+      'Organization ID (org_id) claim mismatch in the ID token; expected "test_org_123", found "test_org_456"'
+    );
+  });
 });