Repository: auth0/auth0-spa-js (ts)

Issue:
Return scope detail during access token retrieval
### Describe the problem you'd like to have solved

Continuing discussion from #709 - currently there's no way to obtain the list of scopes returned during a `/oauth/token` exchange with Auth0 in either `getTokenSilently` or `getTokenWithPopup`

### Describe the ideal solution

Might be an overstep, but [all information](https://github.com/auth0/auth0-spa-js/blob/master/src/api.ts#L6) from the exchange could be returned to the user (e.g. expiration time may be valuable in situations where API clients cannot be setup to retrieve the Access Token via a `Promise` based function and instead needs to be periodically cached outside the library).

## Alternatives and current work-arounds

Parse the info from the returned access token itself, which is [discouraged behavior](https://auth0.com/docs/tokens).

### Additional context

Some discussion/context can be found in #709.


Pull request description:
[SDK-2794] Return token response in getTokenSilently
### Description

This PR adds a new option that allows the developer to get access to the response from the /oauth/token endpoint rather than just the access token when calling `getTokenSilently`.

```js
await auth0.getTokenSilently({ detailedResponse: true });
```

The response is returned with a couple of caveats:

- `refresh_token` is removed
- `scope` returns the scope as returned by `/oauth/token` and does not try to populate it based on the `Auth0Client` configuration

In the case where there was a cache hit and `ignoreCache` is `false`, the properties that would appear in the token endpoint response are extracted (minus `refresh_token`) and the scope is taken from a new cache property `oauthTokenScope`, which stores the scope as returned by `/oauth/token`.

However, this will continue to return `undefined` until the user performs an operation that causes the token endpoint to return different scopes to what was asked, and we don't yet allow the developer to de-scope an access token, so I'm not sure this is a real issue.

### References

Closes #715

### Testing

- [x] This change adds test coverage for new/changed/fixed functionality

### Checklist

- [x] I have added documentation for new/changed functionality in this PR or in auth0.com/docs
- [x] All active GitHub checks for tests, formatting, and security are passing
- [x] The correct base branch is being used, if not `master`


Patch:
diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts
index f4fe2f7..7f2b699 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -13,7 +13,7 @@ import {
   openPopup
 } from './utils';
 
-import { oauthToken, TokenEndpointResponse } from './api';
+import { oauthToken } from './api';
 
 import { getUniqueScopes } from './scope';
 
@@ -67,7 +67,9 @@ import {
   CacheLocation,
   LogoutUrlOptions,
   User,
-  IdToken
+  IdToken,
+  GetTokenSilentlyVerboseResponse,
+  TokenEndpointResponse
 } from './global';
 
 // @ts-ignore
@@ -76,6 +78,16 @@ import { isIE11 } from './user-agent';
 import { singlePromise, retryPromise } from './promise-utils';
 import { CacheKeyManifest } from './cache/key-manifest';
 
+/**
+ * @ignore
+ */
+type GetTokenSilentlyResult = TokenEndpointResponse & {
+  decodedToken: ReturnType<typeof verifyIdToken>;
+  scope: string;
+  oauthTokenScope?: string;
+  audience: string;
+};
+
 /**
  * @ignore
  */
@@ -661,15 +673,13 @@ export default class Auth0Client {
       transaction.organizationId
     );
 
-    const cacheEntry = {
+    await this.cacheManager.set({
       ...authResult,
       decodedToken,
       audience: transaction.audience,
       scope: transaction.scope,
       client_id: this.options.client_id
-    };
-
-    await this.cacheManager.set(cacheEntry);
+    });
 
     this.cookieStorage.save(this.isAuthenticatedCookieName, true, {
       daysUntilExpire: this.sessionCheckExpiryDays
@@ -725,6 +735,26 @@ export default class Auth0Client {
   }
 
   /**
+   * Fetches a new access token and returns the response from the /oauth/token endpoint, omitting the refresh token.
+   *
+   * @param options
+   */
+  public async getTokenSilently(
+    options: GetTokenSilentlyOptions & { detailedResponse: true }
+  ): Promise<GetTokenSilentlyVerboseResponse>;
+
+  /**
+   * Fetches a new access token and returns it.
+   *
+   * @param options
+   */
+  public async getTokenSilently(
+    options?: GetTokenSilentlyOptions
+  ): Promise<string>;
+
+  /**
+   * Fetches a new access token, and either returns just the access token (the default) or the response from the /oauth/token endpoint, depending on the `detailedResponse` option.
+   *
    * ```js
    * const token = await auth0.getTokenSilently(options);
    * ```
@@ -750,7 +780,9 @@ export default class Auth0Client {
    *
    * @param options
    */
-  public async getTokenSilently(options: GetTokenSilentlyOptions = {}) {
+  public async getTokenSilently(
+    options: GetTokenSilentlyOptions = {}
+  ): Promise<string | GetTokenSilentlyVerboseResponse> {
     const { ignoreCache, ...getTokenOptions } = {
       audience: this.options.audience,
       ignoreCache: false,
@@ -768,20 +800,23 @@ export default class Auth0Client {
     );
   }
 
-  private async _getTokenSilently(options: GetTokenSilentlyOptions = {}) {
+  private async _getTokenSilently(
+    options: GetTokenSilentlyOptions = {}
+  ): Promise<string | GetTokenSilentlyVerboseResponse> {
     const { ignoreCache, ...getTokenOptions } = options;
 
     // Check the cache before acquiring the lock to avoid the latency of
     // `lock.acquireLock` when the cache is populated.
     if (!ignoreCache) {
-      const accessToken = await this._getAccessTokenFromCache({
+      const entry = await this._getEntryFromCache({
         scope: getTokenOptions.scope,
         audience: getTokenOptions.audience || 'default',
-        client_id: this.options.client_id
+        client_id: this.options.client_id,
+        getDetailedEntry: options.detailedResponse
       });
 
-      if (accessToken) {
-        return accessToken;
+      if (entry) {
+        return entry;
       }
     }
 
@@ -795,14 +830,15 @@ export default class Auth0Client {
         // Check the cache a second time, because it may have been populated
         // by a previous call while this call was waiting to acquire the lock.
         if (!ignoreCache) {
-          const accessToken = await this._getAccessTokenFromCache({
+          const entry = await this._getEntryFromCache({
             scope: getTokenOptions.scope,
             audience: getTokenOptions.audience || 'default',
-            client_id: this.options.client_id
+            client_id: this.options.client_id,
+            getDetailedEntry: options.detailedResponse
           });
 
-          if (accessToken) {
-            return accessToken;
+          if (entry) {
+            return entry;
           }
         }
 
@@ -819,6 +855,22 @@ export default class Auth0Client {
           daysUntilExpire: this.sessionCheckExpiryDays
         });
 
+        if (options.detailedResponse) {
+          const {
+            id_token,
+            access_token,
+            oauthTokenScope,
+            expires_in
+          } = authResult;
+
+          return {
+            id_token,
+            access_token,
+            ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
+            expires_in
+          };
+        }
+
         return authResult.access_token;
       } finally {
         await lock.releaseLock(GET_TOKEN_SILENTLY_LOCK_KEY);
@@ -854,7 +906,7 @@ export default class Auth0Client {
       getTokenOptions.audience || this.options.audience;
 
     if (!ignoreCache) {
-      const accessToken = await this._getAccessTokenFromCache({
+      const accessToken = await this._getEntryFromCache({
         scope: getTokenOptions.scope,
         audience: getTokenOptions.audience || 'default',
         client_id: this.options.client_id
@@ -872,7 +924,7 @@ export default class Auth0Client {
 
     await this.loginWithPopup(getTokenOptions, config);
 
-    return await this._getAccessTokenFromCache({
+    return await this._getEntryFromCache({
       scope: getTokenOptions.scope,
       audience: getTokenOptions.audience || 'default',
       client_id: this.options.client_id
@@ -965,7 +1017,7 @@ export default class Auth0Client {
 
   private async _getTokenFromIFrame(
     options: GetTokenSilentlyOptions
-  ): Promise<any> {
+  ): Promise<GetTokenSilentlyResult> {
     const stateIn = encode(createRandomString());
     const nonceIn = encode(createRandomString());
     const code_verifier = createRandomString();
@@ -1052,6 +1104,7 @@ export default class Auth0Client {
         ...tokenResult,
         decodedToken,
         scope: params.scope,
+        oauthTokenScope: tokenResult.scope,
         audience: params.audience || 'default'
       };
     } catch (e) {
@@ -1066,7 +1119,7 @@ export default class Auth0Client {
 
   private async _getTokenUsingRefreshToken(
     options: GetTokenSilentlyOptions
-  ): Promise<any> {
+  ): Promise<GetTokenSilentlyResult> {
     options.scope = getUniqueScopes(
       this.defaultScope,
       this.options.scope,
@@ -1095,8 +1148,13 @@ export default class Auth0Client {
 
     let tokenResult: TokenEndpointResponse;
 
-    const { scope, audience, ignoreCache, timeoutInSeconds, ...customOptions } =
-      options;
+    const {
+      scope,
+      audience,
+      ignoreCache,
+      timeoutInSeconds,
+      ...customOptions
+    } = options;
 
     const timeout =
       typeof options.timeoutInSeconds === 'number'
@@ -1143,20 +1201,23 @@ export default class Auth0Client {
       ...tokenResult,
       decodedToken,
       scope: options.scope,
+      oauthTokenScope: tokenResult.scope,
       audience: options.audience || 'default'
     };
   }
 
-  private async _getAccessTokenFromCache({
+  private async _getEntryFromCache({
     scope,
     audience,
-    client_id
+    client_id,
+    getDetailedEntry = false
   }: {
     scope: string;
     audience: string;
     client_id: string;
+    getDetailedEntry?: boolean;
   }) {
-    const cache = await this.cacheManager.get(
+    const entry = await this.cacheManager.get(
       new CacheKey({
         scope,
         audience,
@@ -1165,6 +1226,19 @@ export default class Auth0Client {
       60 // get a new token if within 60 seconds of expiring
     );
 
-    return cache && cache.access_token;
+    if (entry && entry.access_token) {
+      if (getDetailedEntry) {
+        const { id_token, access_token, oauthTokenScope, expires_in } = entry;
+
+        return {
+          id_token,
+          access_token,
+          ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
+          expires_in
+        };
+      }
+
+      return entry.access_token;
+    }
   }
 }
diff --git a/src/api.ts b/src/api.ts
index d631b1c..21ad862 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,16 +1,8 @@
-import { TokenEndpointOptions } from './global';
+import { TokenEndpointOptions, TokenEndpointResponse } from './global';
 import { DEFAULT_AUTH0_CLIENT } from './constants';
 import { getJSON } from './http';
 import { createQueryParams } from './utils';
 
-export type TokenEndpointResponse = {
-  id_token: string;
-  access_token: string;
-  refresh_token?: string;
-  expires_in: number;
-  scope?: string;
-};
-
 export async function oauthToken(
   {
     baseUrl,
diff --git a/src/cache/shared.ts b/src/cache/shared.ts
index af45428..34702b0 100644
--- a/src/cache/shared.ts
+++ b/src/cache/shared.ts
@@ -68,6 +68,7 @@ export type CacheEntry = {
   scope: string;
   client_id: string;
   refresh_token?: string;
+  oauthTokenScope?: string;
 };
 
 export type WrappedCacheEntry = {
diff --git a/src/global.ts b/src/global.ts
index 24ce8fd..7ef2670 100644
--- a/src/global.ts
+++ b/src/global.ts
@@ -345,6 +345,14 @@ export interface GetTokenSilentlyOptions {
    */
   timeoutInSeconds?: number;
 
+  /**
+   * If true, the full response from the /oauth/token endpoint (or the cache, if the cache was used) is returned
+   * (minus `refresh_token` if one was issued). Otherwise, just the access token is returned.
+   *
+   * The default is `false`.
+   */
+  detailedResponse?: boolean;
+
   /**
    * If you need to send custom parameters to the Authorization Server,
    * make sure to use the original parameter name.
@@ -462,6 +470,17 @@ export interface TokenEndpointOptions {
   [key: string]: any;
 }
 
+/**
+ * @ignore
+ */
+export type TokenEndpointResponse = {
+  id_token: string;
+  access_token: string;
+  refresh_token?: string;
+  expires_in: number;
+  scope?: string;
+};
+
 /**
  * @ignore
  */
@@ -572,3 +591,8 @@ export type FetchOptions = {
   body?: string;
   signal?: AbortSignal;
 };
+
+export type GetTokenSilentlyVerboseResponse = Omit<
+  Partial<TokenEndpointResponse>,
+  'refresh_token'
+>;


Test patch:
diff --git a/__tests__/Auth0Client/getTokenSilently.test.ts b/__tests__/Auth0Client/getTokenSilently.test.ts
index 320ab6a..709704c 100644
--- a/__tests__/Auth0Client/getTokenSilently.test.ts
+++ b/__tests__/Auth0Client/getTokenSilently.test.ts
@@ -10,11 +10,7 @@ import * as api from '../../src/api';
 
 import { expectToHaveBeenCalledWithAuth0ClientParam } from '../helpers';
 
-import {
-  GET_TOKEN_SILENTLY_LOCK_KEY,
-  TEST_AUDIENCE,
-  TEST_ORG_ID
-} from '../constants';
+import { GET_TOKEN_SILENTLY_LOCK_KEY, TEST_ORG_ID } from '../constants';
 
 // @ts-ignore
 import { acquireLockSpy } from 'browser-tabs-lock';
@@ -92,6 +88,7 @@ describe('Auth0Client', () => {
 
     mockWindow.open = jest.fn();
     mockWindow.addEventListener = jest.fn();
+
     mockWindow.crypto = {
       subtle: {
         digest: () => 'foo'
@@ -100,6 +97,7 @@ describe('Auth0Client', () => {
         return '123';
       }
     };
+
     mockWindow.MessageChannel = MessageChannel;
     mockWindow.Worker = {};
     jest.spyOn(scope, 'getUniqueScopes');
@@ -268,6 +266,7 @@ describe('Auth0Client', () => {
 
     it('calls the token endpoint with the correct params when passing redirect uri and using refresh tokens', async () => {
       const redirect_uri = 'https://custom';
+
       const auth0 = setup({
         useRefreshTokens: true
       });
@@ -330,6 +329,7 @@ describe('Auth0Client', () => {
       });
 
       jest.spyOn(<any>api, 'oauthToken');
+
       jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
         access_token: TEST_ACCESS_TOKEN,
         refresh_token: TEST_REFRESH_TOKEN,
@@ -1605,20 +1605,13 @@ describe('Auth0Client', () => {
       const auth0 = setup();
 
       await loginWithRedirect(auth0);
-
       mockFetch.mockReset();
-
       jest.spyOn(auth0, 'logout');
-      jest.spyOn(utils, 'runIframe').mockRejectedValue(
-        GenericError.fromPayload({
-          error: 'login_required',
-          error_description: 'login_required'
-        })
-      );
 
       await expect(
         auth0.getTokenSilently({ ignoreCache: true })
       ).rejects.toThrow('login_required');
+
       expect(auth0.logout).toHaveBeenCalledWith({ localOnly: true });
     });
 
@@ -1626,12 +1619,12 @@ describe('Auth0Client', () => {
       const auth0 = setup();
 
       await loginWithRedirect(auth0);
-
       mockFetch.mockReset();
-
       jest.spyOn(auth0, 'logout');
+
       const originalWindow = { ...window };
       const windowSpy = jest.spyOn(global as any, 'window', 'get');
+
       windowSpy.mockImplementation(() => ({
         ...originalWindow,
         crossOriginIsolated: true
@@ -1640,7 +1633,154 @@ describe('Auth0Client', () => {
       await expect(
         auth0.getTokenSilently({ ignoreCache: true })
       ).rejects.toHaveProperty('error', 'login_required');
+
       expect(auth0.logout).toHaveBeenCalledWith({ localOnly: true });
+      windowSpy.mockRestore();
+    });
+
+    it('returns the full token response when "detailedResponse: true"', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0);
+
+      jest.spyOn(<any>utils, 'runIframe').mockResolvedValue({
+        state: TEST_STATE
+      });
+
+      mockFetch.mockResolvedValue(
+        fetchResponse(true, {
+          id_token: TEST_ID_TOKEN,
+          refresh_token: TEST_REFRESH_TOKEN,
+          access_token: TEST_ACCESS_TOKEN,
+          expires_in: 86400
+        })
+      );
+
+      const response = await auth0.getTokenSilently({
+        ignoreCache: true,
+        detailedResponse: true
+      });
+
+      // No refresh_token included here, or oauthTokenScope
+      expect(response).toStrictEqual({
+        id_token: TEST_ID_TOKEN,
+        access_token: TEST_ACCESS_TOKEN,
+        expires_in: 86400
+      });
+    });
+
+    it('returns the full token response with scopes when "detailedResponse: true"', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0);
+
+      mockFetch.mockResolvedValue(
+        fetchResponse(true, {
+          id_token: TEST_ID_TOKEN,
+          refresh_token: TEST_REFRESH_TOKEN,
+          access_token: TEST_ACCESS_TOKEN,
+          expires_in: 86400,
+          scope: 'read:messages'
+        })
+      );
+
+      const response = await auth0.getTokenSilently({
+        ignoreCache: true,
+        detailedResponse: true
+      });
+
+      // No refresh_token included here, or oauthTokenScope
+      expect(response).toStrictEqual({
+        id_token: TEST_ID_TOKEN,
+        access_token: TEST_ACCESS_TOKEN,
+        expires_in: 86400,
+        scope: 'read:messages'
+      });
+    });
+
+    it('returns the full response when "detailedReponse: true" and using cache', async () => {
+      const auth0 = setup();
+
+      await loginWithRedirect(auth0);
+
+      const runIframeSpy = jest
+        .spyOn(<any>utils, 'runIframe')
+        .mockResolvedValue({
+          state: TEST_STATE
+        });
+
+      const response = await auth0.getTokenSilently({
+        detailedResponse: true
+      });
+
+      // No refresh_token included here, or oauthTokenScope
+      expect(response).toStrictEqual({
+        id_token: TEST_ID_TOKEN,
+        access_token: TEST_ACCESS_TOKEN,
+        expires_in: 86400
+      });
+
+      expect(runIframeSpy).not.toHaveBeenCalled();
+    });
+
+    it('returns the full response with scopes when "detailedResponse: true" and using cache', async () => {
+      const auth0 = setup({
+        scope: 'read:messages write:messages'
+      });
+
+      const runIframeSpy = jest
+        .spyOn(<any>utils, 'runIframe')
+        .mockResolvedValue({
+          state: TEST_STATE
+        });
+
+      // Get the cache into the right state
+      await loginWithRedirect(auth0);
+
+      mockFetch.mockResolvedValue(
+        fetchResponse(true, {
+          id_token: TEST_ID_TOKEN,
+          refresh_token: TEST_REFRESH_TOKEN,
+          access_token: TEST_ACCESS_TOKEN,
+          expires_in: 86400,
+          scope: 'read:messages'
+        })
+      );
+
+      jest.spyOn(auth0['cacheManager'], 'set');
+
+      await auth0.getTokenSilently({
+        ignoreCache: true,
+        scope: 'read:messages'
+      });
+
+      expect(auth0['cacheManager'].set).toHaveBeenCalledWith(
+        expect.objectContaining({
+          scope: 'openid profile email read:messages write:messages',
+          oauthTokenScope: 'read:messages'
+        })
+      );
+
+      // runIframe will have been called while setting up this test, we'll clear it here
+      // to verify that the _next_ call to getTokenSilently uses the cache
+      runIframeSpy.mockClear();
+
+      // Get a full response from the cache - should return
+      // oauthTokenScope in the scope property
+      const response = await auth0.getTokenSilently({
+        detailedResponse: true,
+        scope: 'read:messages'
+      });
+
+      // No refresh_token included here, or oauthTokenScope
+      expect(response).toStrictEqual({
+        id_token: TEST_ID_TOKEN,
+        access_token: TEST_ACCESS_TOKEN,
+        expires_in: 86400,
+        scope: 'read:messages'
+      });
+
+      expect(runIframeSpy).not.toHaveBeenCalled();
     });
   });
 });