Why can I only authenticate/grant once per URL regeneration?

Description

I have a Meeting SDK app that currently uses login/password details, and I’m trying to migrate this to OAuth. I’ve enabled OAuth for the app, and used a custom URL scheme to implement PKCE. (I posted earlier about custom schemes failing, but I suspect it may be valid just for SDK apps… not sure.)

I’m able to successfully go through the process of:

  1. Set appropriate Windows registry entries for my custom URL scheme
  2. Start a browser with the login URL, which displays the authorisation page
  3. Receive the code when my app is started
  4. Exchange the code for an access token
  5. Retrieve the user’s details
  6. Retrieve the user’s ZAK

(I haven’t actually tried starting a meeting with that ZAK yet, but I’ve had that working before.)

That all works once. But if I then go through the steps (from 2) again, the browser doesn’t show the authorisation page - it just shows an empty page then redirects to the app with a code. That code then can’t be exchanged for an access token - I receive an HTTP 400 error with content of:

{
    "reason":"Invalid request : Redirect URI mismatch.",
    "error":"invalid_grant"
}

This makes no sense to me, as I’ve provided the exact same redirect URI as before - it’s literally the same code that worked previously.

If I go to the App Marketplace, manage the App, go to the “Local Test” section and hit “Regenerate” by the testable URL, without having made any changes to the app, everything reverts back to working - the authorisation page comes up, the code is returned appropriately, and I can exchange that code for an access/refresh token.

While obviously I would normally use the refresh token to continue accessing the API, so wouldn’t expect to normally need to go through the authorisation page multiple times, I’d still expect this to work - there are any number of reasons why I may need to reauthorise the app for the same user, e.g. a network issue when refreshing the token which means that the Zoom token server expects a “new” refresh token that was never received.

In the “working” state, the first request (to https://zoom.us/oauth/authorize) is redirected to https://zoom.us/oauth/signin, which in turn redirects back to the original authorisation URL with an additional _zmp_login_state query parameter - that request has a 200 response and loads the authorisation page.

In the “broken” state, the first request (to https://zoom.us/oauth/authorize) succeeds with a 200 response containing HTML that just redirects via an HTML script.

Things I’ve tried when diagnosing this:

  • Using a different browser
  • Using the first-received-and-working code a second time. (I’m glad this one failed, but I thought it worth a try.)
  • Adding _zmp_login_state=broken to the first URL to attempt to get the token server to just go through the full authorisation…
  • Testing using the production credentials - exactly the same results

So it feels like there are two problems here:

  1. Failure to display the authorisation page if the user has previously authorised the app (since the last time the “regenerate URL” button was pressed)
  2. Issuing of a code which can’t be exchanged for an access token. Even if Zoom deliberately takes a stance of “if they’ve authorised it once, it stays authorised”, there’s no point in redirecting using a code that doesn’t work…

Error

(See full description.)

Which App?
Knowing the app can help us to identify your issue faster. Please link the ones you need help/have a question with.

https://marketplace.zoom.us/develop/apps/ga9SYQIaS7u6alcz9axj9g

How To Reproduce (If applicable)

(See full description.)

I’ve just tried this again using a server-side redirect URI instead of PKCE with a custom URI scheme, and observed the same problem: the first login is fine, but trying to log in again generates a code that can’t be exchanged for an access token.

Hi, @jonskeet,

Thank you for posting in the Developer Forum. Generally, the redirect URI mismatch error is thrown when the URL is not an exact match or when a character is missing. Would you be able to share the exact redirect URL entered and the one used when that error is thrown? I’ve found success completing the PKCE auth flow by removing “/” at the end of the entered redirect URL. Here is a video of my reproducing and resolving the "Invalid request: Redirect URI mismatch error.

This is expected behavior. Once a user installs/authorizes the OAuth app, they stay authorized. So regenerate the URL will cause the auth page to display and the app’s authorized users can continue to use it unimpeded.

There is a known bug in the PCKE flow where our auth server expects a trailing "= " at the end of the URL safe encode code_challenge on the first request (to https://zoom.us/oauth/authorize)
Ex. code_challenge=Pil9tXLkrYzY25lMbnNj-dvHUo0zgQHYVAVogI5ndDE=

If you did not append a trailing “=” to the code challenge, the code does not work. Can try to add a trailing “=” to the code challenge and let us know the results.

Hi @donte.S,

Thanks for the reply, but I think you may have misunderstood the situation.

Everything works, the first time. Then the exact same code path fails. So I really don’t think it’s a problem with PKCE, or the code challenge, or the redirect URI. The problem is that after the user has authenticated once, a second visit to the authentication URL still generates a code, but that code can’t be exchanged for an access token.

(Also note that this happens without PKCE being involved at all - if I redirect to a server-side handler to exchange the code for a token, that only works once, too.)

Once a user installs/authorizes the OAuth app, they stay authorized. So regenerate the URL will cause the auth page to display and the app’s authorized users can continue to use it unimpeded.

But what about the expected behaviour without regenerating the URL? Suppose the same user wants to authenticate another instance of the app from a different machine, for example? I’d expect the OAuth server to generate a second code, which can be exchanged for a second access/refresh token pair, which would be independent of the access/refresh token used on the first machine. That doesn’t appear to be the case. (Even without the “second machine” situation, the app shouldn’t take any kind of “reset” on the app developer’s part if a user’s access/refresh token is lost for some reason - they should be able to reauthorize the app to get another code that can be exchanged for another access/refresh token.)

Note: having just seen your video about Postman, I’ve tried that and that does work, allowing multiple tokens to be generated. So I can only assume there really is a problem somewhere with my code, but I still find it baffling that the first request would succeed and the second (and all subsequent ones) fail.

I’ll try some varied redirect URIs - but the server-side one I’m using already doesn’t end with a / so I wouldn’t expect that to be the issue.

@jonskeet,

Glad to hear that that workaround was helpful. You should know that our engineers are working on a fix to better align with the industry standard. However, I think you have a great point that is weird the first request worked, but all subsequent request fails. I have not been able to reproduce that behavior nor have encountered that scenario. I’ll keep testing and place an internal ticket if needed.

To that end, can you share if you were able to make a request with that access token? Also, in your testing, can you also add your URLs to the apps allow list?

Just to clarify, using Postman isn’t really a workaround, so much as an alternative test. I need to spend some more time on that over the weekend to see whether I can figure out the different paths being taken. (I’m hoping I’ll still be able to see all the browser interactions involved.)

In terms of reproducing this, the simplest option for me is to write a small Google Cloud Function in C# that can be deployed and which will attempt to exchange the code for a token, then use the token, and log what happens. Would that be something you’d be able to use for testing? If so, I’ll happily share that (and write deployment instructions etc) over the weekend.

To that end, can you share if you were able to make a request with that access token?

Yes, I think that every time I’ve managed to exchange a code for an access token, I’ve been able to use that access token.

Also, in your testing, can you also add your URLs to the apps allow list?

I’m not quite sure what you mean by that. Could you clarify? (I haven’t had any problems adding URLs to the allow list, in terms of the Marketplace UI, since using my SDK app - even custom scheme URLs are accepted for my SDK app, even though they aren’t for an “OAuth app”.)

@donte.S: Okay, here are the steps I’ve used to reproduce the problem without any reference to my other software. I’ve attempted to be exhaustive in the instructions, which is why it’s such a long list. If you don’t have a Google Cloud Platform project, that makes reproducing it harder - but the core C# code could easily be deployed on other Cloud Platforms. (And even if you’re not able to reproduce the steps yourself, hopefully you’ll be able to understand them and could point out any mistakes I’ve made.) Obviously this isn’t the kind of code I’d normally write, in various ways, and it’s very much just for testing.

Prerequisites: a Zoom Marketplace app with OAuth enabled. My one is an SDK app.

  1. Create a Google Cloud Platform account at https://console.cloud.google.com/
  2. Go to the Cloud Functions console: Google Cloud Platform
  3. Click “Create Function”
  4. Note the function name - you don’t need to change it (or the environment/region)
  5. Under “Trigger”, select “Allow unauthenticated invocations” and click “Save”. (The rest of the defaults are fine.)
  6. Click “Next” at the bottom left of the page, to show the code editor screen
  7. In the top left, in the “Runtime” dropdown, select “.NET Core 3.1”
  8. Copy the code shown below into the code editor on the right (completely replacing the existing code), and edit the client ID and client secret to the values from the Zoom Marketplace App Credentials page.
  9. Click “Deploy” at the bottom of the page
  10. In the list of functions, the function will be shown as deploying. Click on the function name to go into the details.
  11. Go to the “Trigger” tab to get the URL for the function.
  12. Click the two boxes next to “Trigger URL” to copy the URL to the clipboard
  13. In the Zoom Marketplace App Credentials page, copy the function URL into “Redirect URL for OAuth” and into the “OAuth Allow List” section
  14. In the Zoom Marketplace Scopes page, add the “user:read” scope. (I’ve also got the “user_zak:read” scope in my app, which explains the response below - but I can’t imagine that would change this…)
  15. In the Zoom Marketplace Local Test page, generate the testable URL and copy it to the clipboard
  16. In a new browser tab, visit that URL and authorize the app.
  17. Observe the result - mine is shown below (with sensitive information redacted)
  18. In another new browser tab, visit the login URL again and observe the result - mine is shown below

First result:

Received code: '[redacted]'. Exchanging code for token.

Token response status code: OK 200

Token response body: {"access_token":"[redacted]","token_type":"bearer","refresh_token":"[redacted]","expires_in":3599,"scope":"user:read user_zak:read"}

Parsed access token as '[redacted]'. Making API request.

API response status code: OK 200

API response body: { [redacted, but contains my account details, name, email etc ] }

Second result:

Received code: '[redacted - but looks okay]'. Exchanging code for token.

Token response status code: BadRequest 400

Token response body: {"reason":"Invalid request : Redirect URI mismatch.","error":"invalid_grant"}

Token response indicated failure. Aborting.

Code:

using Google.Cloud.Functions.Framework;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace SimpleHttpFunction
{
    public class Function : IHttpFunction
    {
        // Copy these from the Zoom Marketplace App Credentials page.
        private const string ClientId = "...";
        private const string ClientSecret = "...";

        public async Task HandleAsync(HttpContext context)
        {
            string code = context.Request.Query["code"];
            var response = context.Response;
            response.ContentType = "text/plain";

            try
            {
                await response.WriteAsync($"Received code: '{code}'. Exchanging code for token.\n\n");
                var client = new HttpClient();

                // Request 1: exchange the code for an access/refresh token pair.
                var formParameters = new Dictionary<string, string>
                {
                    { "grant_type", "authorization_code" },
                    { "code", code },
                };
                string clientAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}"));
                var tokenRequest = new HttpRequestMessage
                {
                    RequestUri = new Uri("https://zoom.us/oauth/token"),
                    Method = HttpMethod.Post,
                    Headers = { Authorization = new AuthenticationHeaderValue("Basic", clientAuth) },
                    Content = new FormUrlEncodedContent(formParameters)
                };

                var tokenResponse = await client.SendAsync(tokenRequest);
                var tokenResponseText = await tokenResponse.Content.ReadAsStringAsync();
                await response.WriteAsync($"Token response status code: {tokenResponse.StatusCode} {(int)tokenResponse.StatusCode}\n\n");
                await response.WriteAsync($"Token response body: {tokenResponseText}\n\n");
                if (!tokenResponse.IsSuccessStatusCode)
                {
                    await response.WriteAsync("Token response indicated failure. Aborting.");
                    return;
                }

                // Parse the JSON and retrieve the access token.
                var jsonDocument = JsonDocument.Parse(tokenResponseText);
                string accessToken = jsonDocument.RootElement.GetProperty("access_token").GetString();
                await response.WriteAsync($"Parsed access token as '{accessToken}'. Making API request.\n\n");

                // Request 2: an API request for user details, to check the access token works.
                var apiRequest = new HttpRequestMessage
                {
                    RequestUri = new Uri("https://api.zoom.us/v2/users/me"),
                    Method = HttpMethod.Get,
                    Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
                };
                var apiResponse = await client.SendAsync(apiRequest);
                var apiResponseText = await apiResponse.Content.ReadAsStringAsync();
                await response.WriteAsync($"API response status code: {apiResponse.StatusCode} {(int)apiResponse.StatusCode}\n\n");
                await response.WriteAsync($"API response body: {apiResponseText}");
            }
            catch (Exception e)
            {
                await response.WriteAsync($"Exception: {e}");
            }
        }
    }
}

(I’ve just tried Postman again, and unfortunately a lot of the network traffic is via the Postman site, instead of going directly from the browser to the Zoom auth servers - unless I’ve missed something, which is also entirely possible…)

I’ve just confirmed this happens with the production URL as well - but the “reset” for that involves removing the app from my account before authorizing again. (Otherwise it doesn’t show the authorization page, even if I’ve changed the redirect URI.)

@jonskeet,

I appreciate the detailed clarification, and my sincere apologies for not catching your concern from the beginning. I understand now that you’re essentially looking to know why the code generated from the local test URL only works one time. I did some testing in Postman and can confirm the auth code(s) returned will work. You should not have to remove the app from your account before authorizing again. Following the steps below, I was able to successfully exchange an auth code for an access token using the first, second, and all subsequent auth codes:

  1. In the Zoom Marketplace Local Test page, generate the testable URL and copy it to the clipboard

  2. In a new browser tab, visit that URL, sign in, authorize the app, then get the auth code appended to redirect URL, then exchange it for an access token

  3. In another new browser tab, visit the login URL again (no prompt to sign in), get the auth code, then exchange it for an access token.

This workflow maps to steps 15-18 of your instructions, so I suspect something may be happening behind the covers with the redirect URL in the code. I’ll work to see If I can reproduce the behavior with your instructions and let you know what I learn.

@donte.S: Thanks for you work on this. I’m very confused as to what’s going on! Presumably it’s a bug somewhere in my code, but then I don’t understand why it would work the first time…

I’ll try it manually as per your experiments, and we’ll see what happens…

@donte.S:

Okay, this is really interesting - because I can reproduce the problem with the steps you provided. I can exchange the first code for an access token, but not the second or any subsequent ones.

I’ve used https://www.google.com as the redirect URL to make it simple to see the generated code, as you demonstrated in our call. (That’s a genius move on your part, btw - I wonder whether it would be worth including it in the docs as a troubleshooting tool.)

Just to check I’ve done the right thing, here are the settings I’ve been using in Postman:

  • POST request to https://zoom.us/oauth/token
  • Leave the default headers, but add Authorization as "Basic " followed by the base64-encoded Client_ID:Client_Secret. (I’ve done this explicitly rather than using Postman’s authorization functionality.)
  • Set the body type to x-www-form-urlencoded
  • Add a parameter “code” with the code value shown on the URL
  • Add a parameter “grant_type” with the value “authorization_code”

That all seems pretty straightforward, and I expect that’s what you did too. Given that the first request works, I suspect the difference is something about our apps or our accounts.

The app I’ve been testing for is an SDK app with OAuth enabled and “Intent to publish: yes” (although I don’t actually intend to publish it) and status “Draft”. I’ve just tested with a second app which is of type OAuth, with “Intent to publish: no”, and status “Ready to publish” - I get exactly the same behavior (success then fail) with that one.

Any ideas about what app or account settings might be triggering this difference?

Hi, @jonskeet,

I was able to reproduce the behavior and isolate the root cause. It seems like the redirect URI, for the outbound access token request, is being modified on the second and ongoing call in some way. To troubleshoot, on the second request, where the user is authenticated, I used the returned auth code to get an access token using Postman. Since the auth code works, the next thing to inspect is how the redirect URI is being sent on the Google Cloud Function outbound request.

Would you be able to test this on your end? Below I’ve posted screenshots of the Postman settings I used:

I’ve also been testing with an Account-level OAuth app with “Intent to publish: yes”, so I don’t think the app type is playing any factor here. Have you tried to add logic to handle already authorized users?

Let me know your thoughts.

Best,
Donte

Firstly: thanks so much for persisting.

Doh! Looks like I wasn’t including the redirect_uri at all on the token exchange step. I’m really, really confused as to why I didn’t do that.

Obviously that’s pretty easy to test… deploying now… and it works!

Have you tried to add logic to handle already authorized users?

I don’t know what that logic would look like (or where it would live). But if I can exchange the code for a new access token regardless of whether they were already authorized, I don’t think it should cause any problems.

Thank you so much - the screenshot was just what I needed to spot where I’d been going wrong.

If there’s any sort of “customer support bonus” that I can recommend you for, please let me know how to do so!

I’m still very confused as to why it works the first time - I’m sure if it had failed from the start, I’d have figured out the problem earlier (and hopefully without needing to take up your time). If you could pass a note to the OAuth team to let them know this is confusing, that would be great.

(Of course now I need to try it with PKCE, but I’m at least somewhat confident it’ll work…)

Awesome, @jonskeet! Glad to hear that helped resolve the behavior you were seeing. I also noticed the redirect URI was not included in the C# code. But could not get it to work when I added it to formParameters as a property value. This is likely because I am not familiar enough with the nuances of debugging C# functions on Google Cloud Platform. Please feel welcome to share the working C# code here. It will greatly benefit others looking to implement a similar solution. I also think this workflow will be a great OAuth PKCE flow guide – I’ll look into getting something published on this topic.

I suspect the reason it worked the first time has to do with the authorization page step in the first request. That is the only thing different I see between the first and second requests to the token endpoint. Nonetheless, I am happy to pass along your feedback to our OAuth team.

Thank you for working with me on this!

Please feel welcome to share the working C# code here. It will greatly benefit others looking to implement a similar solution.

The C# change is really small, and you were nearly there, it was just the parameter name that was slightly wrong, I think - redirect instead of redirect_uri:

var formParameters = new Dictionary<string, string>
{
    { "grant_type", "authorization_code" },
    { "code", code },
    { "redirect_uri", "insert the redirect URI here" }
};

At the moment I’ve just hard-coded the redirect URI in my working function. Ideally the function would work it out dynamically based on the request, but there are some subtleties that make that hard to get right without a bit more work.

If you’d like me to help work with you on the PCKE flow guide, I’d be really happy to do that.

@jonskeet,

Bingo-- that did the trick! In my haste, I missed that typo. With regards to the PKCE flow guide, I think it would be awesome to collaborate. I’ll pm you to set something up.

For other interested folks, here is the modified C# code:

Code:

using Google.Cloud.Functions.Framework;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace SimpleHttpFunction
{
    public class Function : IHttpFunction
    {
        // Copy these from the Zoom Marketplace App Credentials page.
        private const string ClientId = "...";
        private const string ClientSecret = "...";

        public async Task HandleAsync(HttpContext context)
        {
            string code = context.Request.Query["code"];
            var response = context.Response;
            response.ContentType = "text/plain";

            try
            {
                await response.WriteAsync($"Received code: '{code}'. Exchanging code for token.\n\n");
                var client = new HttpClient();

                // Request 1: exchange the code for an access/refresh token pair.
                var formParameters = new Dictionary<string, string>
                {
                    { "grant_type", "authorization_code" },
                    { "code", code },
                    { "redirect_uri", "insert the redirect URI here" },
                };
                string clientAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}"));
                var tokenRequest = new HttpRequestMessage
                {
                    RequestUri = new Uri("https://zoom.us/oauth/token"),
                    Method = HttpMethod.Post,
                    Headers = { Authorization = new AuthenticationHeaderValue("Basic", clientAuth) },
                    Content = new FormUrlEncodedContent(formParameters)
                };

                var tokenResponse = await client.SendAsync(tokenRequest);
                var tokenResponseText = await tokenResponse.Content.ReadAsStringAsync();
                await response.WriteAsync($"Token response status code: {tokenResponse.StatusCode} {(int)tokenResponse.StatusCode}\n\n");
                await response.WriteAsync($"Token response body: {tokenResponseText}\n\n");
                if (!tokenResponse.IsSuccessStatusCode)
                {
                    await response.WriteAsync("Token response indicated failure. Aborting.");
                    return;
                }

                // Parse the JSON and retrieve the access token.
                var jsonDocument = JsonDocument.Parse(tokenResponseText);
                string accessToken = jsonDocument.RootElement.GetProperty("access_token").GetString();
                await response.WriteAsync($"Parsed access token as '{accessToken}'. Making API request.\n\n");

                // Request 2: an API request for user details, to check the access token works.
                var apiRequest = new HttpRequestMessage
                {
                    RequestUri = new Uri("https://api.zoom.us/v2/users/me"),
                    Method = HttpMethod.Get,
                    Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
                };
                var apiResponse = await client.SendAsync(apiRequest);
                var apiResponseText = await apiResponse.Content.ReadAsStringAsync();
                await response.WriteAsync($"API response status code: {apiResponse.StatusCode} {(int)apiResponse.StatusCode}\n\n");
                await response.WriteAsync($"API response body: {apiResponseText}");
            }
            catch (Exception e)
            {
                await response.WriteAsync($"Exception: {e}");
            }
        }
    }
}