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

Issue:
Return auth URL for usage with manual browser redirects
**Please do not report security vulnerabilities here**. The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues.

**Thank you in advance for helping us to improve this library!** Please read through the template below and answer all relevant questions. Your additional work here is greatly appreciated and will help us respond as quickly as possible. For general support or usage questions, use the [Auth0 Community](https://community.auth0.com/) or [Auth0 Support](https://support.auth0.com/). Finally, to avoid duplicates, please search existing Issues before submitting one here. 

By submitting an Issue to this repository, you agree to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). 

### Description

> Provide a clear and concise description of the issue, including what you expected to happen.

My app runs ionic/react with capacitor and the auth0-spa-js sdk.  I am attempting to use the capacitor "Browser" plugin to open the auth0 universal login, because utilizing loginWithRedirect() causes the app to open safari externally.  The problem is, I cannot find how to retrieve the constructed URL loginWithRedirect uses.  It would be nice to have the auth0-spa-js library return the URL that loginWithRedirect generates so we can utilize custom plugins for redirect.  Am I going about this the wrong way?

### Reproduction

> Detail the steps taken to reproduce this error, what was expected, and whether this issue can be reproduced consistently or if it is intermittent.
>
> Where applicable, please include:
>
> - Code sample to reproduce the issue
> - Log files (redact/remove sensitive information)
> - Application settings (redact/remove sensitive information)
> - Screenshots

Run auth0-spa-js sdk with ionic/react quickstart and call loginWithRedirect().  Observe redirection outside of app instead of in SafariViewController.

### Environment

> Please provide the following:

- **Version of this library used:** 1.5.0
- **Version of the platform or framework used, if applicable:** @ionic/react@4
- **Other relevant versions (language, server software, OS, browser):**
- **Other modules/plugins/libraries that might be involved:**



Pull request description:
Add noRedirect flag, Add custom URL parameter
### Description

Currently, the URL which is generated by loginWithRedirect is generated but never returned.  This is fine for all web app use cases, but more flexibility is required for hybrid frameworks such as ionic running on native platforms.  By returning the URL only in a no-op fashion with loginWithRedirect, and allowing custom URLs to be passed to handleRedirectCallback, ionic users can facilitate custom browser based flows while still using mostly the same code as the SPA react quickstart.

- add noRedirect flag for loginWithRedirect for URL return only
- add custom URL parameter to handleRedirectCallback

### Testing

To test, run `await loginWithRedirect({ noRedirect: true })`;
This should return a URL without running `window.location.assign`

- [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 1c57050..4ed5c21 100644
--- a/src/Auth0Client.ts
+++ b/src/Auth0Client.ts
@@ -99,6 +99,46 @@ export default class Auth0Client {
     return parseInt(value, 10) || undefined;
   }
 
+  /**
+   * ```js
+   * await auth0.buildAuthorizeUrl(options);
+   * ```
+   *
+   * Builds an `/authorize` URL for loginWithRedirect using the parameters
+   * provided as arguments. Random and secure `state` and `nonce`
+   * parameters will be auto-generated.
+   *
+   * @param options
+   */
+
+  public async buildAuthorizeUrl(
+    options: RedirectLoginOptions = {}
+  ): Promise<string> {
+    const { redirect_uri, appState, ...authorizeOptions } = options;
+    const stateIn = encodeState(createRandomString());
+    const nonceIn = createRandomString();
+    const code_verifier = createRandomString();
+    const code_challengeBuffer = await sha256(code_verifier);
+    const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
+    const fragment = options.fragment ? `#${options.fragment}` : '';
+    const params = this._getParams(
+      authorizeOptions,
+      stateIn,
+      nonceIn,
+      code_challenge,
+      redirect_uri
+    );
+    const url = this._authorizeUrl(params);
+    this.transactionManager.create(stateIn, {
+      nonce: nonceIn,
+      code_verifier,
+      appState,
+      scope: params.scope,
+      audience: params.audience || 'default'
+    });
+    return url + fragment;
+  }
+
   /**
    * ```js
    * await auth0.loginWithPopup(options);
@@ -212,29 +252,8 @@ export default class Auth0Client {
    * @param options
    */
   public async loginWithRedirect(options: RedirectLoginOptions = {}) {
-    const { redirect_uri, appState, ...authorizeOptions } = options;
-    const stateIn = encodeState(createRandomString());
-    const nonceIn = createRandomString();
-    const code_verifier = createRandomString();
-    const code_challengeBuffer = await sha256(code_verifier);
-    const code_challenge = bufferToBase64UrlEncoded(code_challengeBuffer);
-    const fragment = options.fragment ? `#${options.fragment}` : '';
-    const params = this._getParams(
-      authorizeOptions,
-      stateIn,
-      nonceIn,
-      code_challenge,
-      redirect_uri
-    );
-    const url = this._authorizeUrl(params);
-    this.transactionManager.create(stateIn, {
-      nonce: nonceIn,
-      code_verifier,
-      appState,
-      scope: params.scope,
-      audience: params.audience || 'default'
-    });
-    window.location.assign(url + fragment);
+    const url = await this.buildAuthorizeUrl(options);
+    window.location.assign(url);
   }
 
   /**
@@ -243,8 +262,10 @@ export default class Auth0Client {
    * responses from Auth0. If the response is successful, results
    * will be valid according to their expiration times.
    */
-  public async handleRedirectCallback(): Promise<RedirectLoginResult> {
-    const queryStringFragments = window.location.href.split('?').slice(1);
+  public async handleRedirectCallback(
+    url: string = window.location.href
+  ): Promise<RedirectLoginResult> {
+    const queryStringFragments = url.split('?').slice(1);
     if (queryStringFragments.length === 0) {
       throw new Error('There are no query params available for parsing.');
     }


Test patch:
diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts
index 1110839..0de29d8 100644
--- a/__tests__/index.test.ts
+++ b/__tests__/index.test.ts
@@ -387,7 +387,7 @@ describe('Auth0', () => {
       expect(utils.openPopup).toHaveBeenCalled();
     });
   });
-  describe('loginWithRedirect()', () => {
+  describe('buildAuthorizeUrl()', () => {
     const REDIRECT_OPTIONS = {
       redirect_uri: 'https://redirect.uri',
       appState: TEST_APP_STATE,
@@ -396,13 +396,13 @@ describe('Auth0', () => {
     it('encodes state with random string', async () => {
       const { auth0, utils } = await setup();
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
       expect(utils.encodeState).toHaveBeenCalledWith(TEST_RANDOM_STRING);
     });
     it('creates `code_challenge` by using `utils.sha256` with the result of `utils.createRandomString`', async () => {
       const { auth0, utils } = await setup();
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
       expect(utils.sha256).toHaveBeenCalledWith(TEST_RANDOM_STRING);
       expect(utils.bufferToBase64UrlEncoded).toHaveBeenCalledWith(
         TEST_ARRAY_BUFFER
@@ -411,7 +411,7 @@ describe('Auth0', () => {
     it('creates correct query params', async () => {
       const { auth0, utils } = await setup();
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         client_id: TEST_CLIENT_ID,
         scope: TEST_SCOPES,
@@ -428,7 +428,7 @@ describe('Auth0', () => {
     it('creates correct query params without leeway', async () => {
       const { auth0, utils } = await setup({ leeway: 10 });
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         client_id: TEST_CLIENT_ID,
         scope: TEST_SCOPES,
@@ -449,7 +449,7 @@ describe('Auth0', () => {
         redirect_uri
       });
 
-      await auth0.loginWithRedirect(options);
+      await auth0.buildAuthorizeUrl(options);
 
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         client_id: TEST_CLIENT_ID,
@@ -470,7 +470,7 @@ describe('Auth0', () => {
         redirect_uri
       });
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
 
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         client_id: TEST_CLIENT_ID,
@@ -488,7 +488,7 @@ describe('Auth0', () => {
     it('creates correct query params with custom params', async () => {
       const { auth0, utils } = await setup();
 
-      await auth0.loginWithRedirect({ ...REDIRECT_OPTIONS, audience: 'test' });
+      await auth0.buildAuthorizeUrl({ ...REDIRECT_OPTIONS, audience: 'test' });
       expect(utils.createQueryParams).toHaveBeenCalledWith({
         audience: 'test',
         client_id: TEST_CLIENT_ID,
@@ -503,11 +503,10 @@ describe('Auth0', () => {
         connection: 'test-connection'
       });
     });
-
     it('calls `transactionManager.create` with new transaction', async () => {
       const { auth0, transactionManager } = await setup();
 
-      await auth0.loginWithRedirect(REDIRECT_OPTIONS);
+      await auth0.buildAuthorizeUrl(REDIRECT_OPTIONS);
       expect(transactionManager.create).toHaveBeenCalledWith(
         TEST_ENCODED_STATE,
         {
@@ -519,6 +518,31 @@ describe('Auth0', () => {
         }
       );
     });
+    it('returns the url', async () => {
+      const { auth0 } = await setup();
+
+      const url = await auth0.buildAuthorizeUrl({
+        ...REDIRECT_OPTIONS
+      });
+      expect(url).toBe(
+        `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}`
+      );
+    });
+    it('returns the url when no arguments are passed', async () => {
+      const { auth0 } = await setup();
+
+      const url = await auth0.buildAuthorizeUrl();
+      expect(url).toBe(
+        `https://test.auth0.com/authorize?query=params${TEST_TELEMETRY_QUERY_STRING}`
+      );
+    });
+  });
+  describe('loginWithRedirect()', () => {
+    const REDIRECT_OPTIONS = {
+      redirect_uri: 'https://redirect.uri',
+      appState: TEST_APP_STATE,
+      connection: 'test-connection'
+    };
     it('calls `window.location.assign` with the correct url', async () => {
       const { auth0 } = await setup();
 
@@ -752,6 +776,14 @@ describe('Auth0', () => {
           `code=${TEST_CODE}&state=${TEST_ENCODED_STATE}`
         );
       });
+      it('calls parseQueryResult with a passed URL', async () => {
+        const { auth0, utils } = await localSetup();
+        const customUrl = `https://test.auth0.com?code=${TEST_CODE}&state=${TEST_ENCODED_STATE}`;
+        await auth0.handleRedirectCallback(customUrl);
+        expect(utils.parseQueryResult).toHaveBeenCalledWith(
+          `code=${TEST_CODE}&state=${TEST_ENCODED_STATE}`
+        );
+      });
       it('uses `state` from parsed query to get a transaction', async () => {
         const { auth0, utils, transactionManager } = await localSetup();
         const queryState = 'the-state';