Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AspNetCore3 - a new refresh token on every login #1725

Closed
fhtino opened this issue Dec 30, 2020 · 11 comments
Closed

AspNetCore3 - a new refresh token on every login #1725

fhtino opened this issue Dec 30, 2020 · 11 comments
Assignees
Labels
type: question Request for information or clarification. Not an issue.

Comments

@fhtino
Copy link

fhtino commented Dec 30, 2020

I'm playing with Google.Apis.Auth.AspNetCore3.IntegrationTests sample. Everythins works fine but I see something strange from my point of view.

Every time a user login, Google auth service generates a new set of access_token + refresh_token.
Reading https://developers.google.com/identity/protocols/oauth2#expiration I understand that there is a limit on the number of valid refresh_token issued by Google for an application. Older refresh_token are automatically invalidated.

Imagine this case:

  • user logins and then asks the web-application to store the refresh_token (securely!) server-side
  • when needed, the background processes/scheduled_services server-side continue to call Google services in behalf of the user.
  • after some time, the user comes back to the website and he/she must login again, because of auth-cookie expiration, closed the browser, using another browser, etc.
  • Google auth service issues a new access_token + refresh_token

I'm worried that after many logins, the old refresh_token, stored in the backend, would be no longer valid.

Am I right? Is this risk real? If so, is there a way to login in aspnet core web app without asking for a new refresh_token and asking for it only when really needed, e.g. because I need to store it?

@jskeet
Copy link
Collaborator

jskeet commented Dec 30, 2020

Assigning to @amanda-tarafa as she knows more about this than I do, but please be aware that we're on vacation until Tuesday, so there may be no further responses until then.

@jskeet jskeet added the type: question Request for information or clarification. Not an issue. label Dec 30, 2020
@amanda-tarafa
Copy link
Contributor

I can reply in more detail on Tuesday when I'm back at work. But short answer: yes the old refresh token might stop working for any of the reasons listed on the docs you linked, including, but not only, too many refresh tokens emitted. As it says there your app should be prepared for this, and the easiest way to do that in the scenario you described is to store the new refresh token every time there's one. I'm guessing that if your app can store it the first time, it can store it every time.

I can't remember if you can request an access token without a refresh token or if Google.Apis.Auth.AspNetCore* would currently allow it. But even if it were possible, any of the other refresh token invalidation conditions could be met at any time and your app should be prepared to deal with that.

@fhtino
Copy link
Author

fhtino commented Dec 31, 2020

@amanda-tarafa thank you for your valuable feedback. I was asking the same: is it possible to get an access_topken without a refresh_token? Reading documentation here https://developers.google.com/identity/protocols/oauth2/web-server#httprest_3 it seems to be possible setting access_type=online, instead of offline.

Google responds to this request by returning a JSON object that contains a short-lived access token and a refresh token. Note that the refresh token is only returned if your application set the access_type parameter to offline in the initial request to Google's authorization server.

In Google APIs client Library for .NET I can't find an option to toggle from offline to online.

@amanda-tarafa
Copy link
Contributor

I'll take a closer look next week at Google.Apis.Auth and Google.Apis.Auth.AspNetCore* to evaluate surfacing this as a new feature if it's not possible already. Two things though:

  • There's a concerted effort going on to standardize Google Auth libraries across all languages, so a feature added to one language should be added to the rest. This means that feature prioritization does not only depend on the needs/requests of the .NET library.
  • Your refresh token can still expire or be revoked in which case it will stop working and your user will have to authenticate again, neither of your web app or backend service will work until then. The use case you describe is actually the intended use case for the offline auth code request, i.e. you will use the access and refresh tokens when the user is not using your app and thus they can't authenticate. I would strongly recommend that you simply store the new refresh token every time there's one. You can share the token storage between your web app and backend service and this will happen automatically, the token storage is managed by Google.Apis.Auth. I can give more details about this next week.

@amanda-tarafa
Copy link
Contributor

Having taken a closer look:

  • I can confirm that Google.Apis.Auth.AspNetCore* does not currently support requesting online access tokens, that is, an access token request that does not emit a refresh token. We can evaluate supporting this as a new feature, but I'm not entirely certain we'd want to. And in any case some coordination would be required with Google Auth libraries in other languages to make sure that we provide a similar experience. So, even if we decided to support this, it won't happen inmediately.
  • As I suggested, you can store the refresh token every time it is emitted to the web app so that your backend service can always access the latest refresh token. It's not as straightforward as I originally thought but it's definetely doable.
    • To access the newly emitted tokens, on Startup.cs (or similar):
public void ConfigureServices(IServiceCollection services)
{
    ...
    // This configures Google.Apis.Auth.AspNetCore3 for use in this app.
    services
        ...
        .AddGoogleOpenIdConnect(options =>
        {
            options.ClientId = "YOUR-CLIENT-ID";
            options.ClientSecret = "YOUR-CLIENT-SECRET";
            options.Events.OnTokenValidated += OnTokenValidated;
        });

   static Task OnTokenValidated(TokenValidatedContext ctx)
   {
       string accessToken = ctx.TokenEndpointResponse.AccessToken;
       string refreshToken = ctx.TokenEndpointResponse.RefreshToken;
       // You should have enough claims here (email, for instance)
       // to be able to identify your user.
       ClaimsPrincipal principal = ctx.Principal;

       // Store the tokens so the backend service can access them.
       // How you store them depends on how you are using them on the backend service.

       return Task.CompletedTask;
    }
}
  • If on your backend service you are using something as described here you can use FileDataStore (if both the web app and the backend service are running on the same machine) or your own implementation of Google.Apis.Util.Store.IDataStore to store the tokens on the web application so they can be accessible on the backend service. On the backend service you only need to make sure to pass the correct IDataStore to GoogleWebAuthorizationBroker.AuthorizeAsync. And on the web application, whether you are using FileDataStore or your own implementation of IDataStore you store the tokens as a key/value pair, where the key is the user identifier and the value is a Google.Apis.Auth.OAuth2.Responses.TokenResponse that you can build from the values in TokenValidatedContext.TokenEndpointResponse. For this scenario, OnTokenValidated from above would look something like this:
static async Task OnTokenValidated(TokenValidatedContext ctx)
{
	TokenResponse tokens = new TokenResponse
	{
		AccessToken = ctx.TokenEndpointResponse.AccessToken,
		RefreshToken = ctx.TokenEndpointResponse.RefreshToken,
		ExpiresInSeconds = long.Parse(ctx.TokenEndpointResponse.ExpiresIn),
		...
	};
	
	// You should have enough claims here (email, for instance)
	// to be able to identify your user.
	ClaimsPrincipal principal = ctx.Principal;
	string userId = principal.FindFirst("CLAIM-YOU-USE").Value;

	// Data store shared between your web app and backend service.
	// You can use FileDataStore if they are running on the same machine.
	IDataStore sharedStore = ...;

	await sharedStore.StoreAsync(userId, tokens).ConfigureAwait(false);
}

I know this is a lot of info, and you'll probably have to write a little bit more code than you were expecting to. But do let me know if you run into issues or if this is not helpful at all.

@fhtino
Copy link
Author

fhtino commented Jan 5, 2021

Your code is very interesting and full of tips. options.Events.OnTokenValidated += OnTokenValidated; is exactly what I was looking for intercepting the new refresh-token.
I must correctly manage incremental authorization, too. The first refresh_token could be only a "basic" one, without all the required scopes. Then, after browsing the right page/controller-action I will receive the refresh token with all the required scopes. I have to store this one fro my backend service. I see I can check the scopes with string scopes = ctx.TokenEndpointResponse.Scope; inside OnTokenValidated
I'll try everything as soon as possible and I'll let you know.

@amanda-tarafa
Copy link
Contributor

I must correctly manage incremental authorization, too.

Yes, on Google.Apis.Auth.AspNetCore3.IntegrationTests you can take a look at the List Google Drive files and List Calendars operations to see how to manage incremental auth via attributes or code. You shouldn't need to do much more than what's done in those operations.

@LindaLawton
Copy link
Collaborator

LindaLawton commented Jan 7, 2021

The refresh tokens shouldn't be expiring as you are allowed to have up to 50 outstanding refresh tokens for a user before the oldest expires.

You should only be getting a new refresh token if the user is logging in again. (ie shown the consent screen and consenting to your access). If you are just using a refresh token to request a new access token this should not be causing a new refresh token to be returned by the auth server.

@amanda-tarafa
Copy link
Contributor

amanda-tarafa commented Jan 7, 2021

The refresh tokens shouldn't be expiring as you are allowed to have up to 50 outstanding refresh tokens for a user before the oldest expires.

But certainly once the 51st refresh token has been emmitted, the oldest one will become invalid. For users that frequently clear cookies or log in from different devices, or simply log out of your application, this cap can very reasonably be hit within a few months, or even days. Also, the caps are not guarenteed to remain unchanged, docs refer mostly to the existance of caps but not to a given cap value for this very reason. Applications shouldn't depend on the cap being 50, it could be changed to a different, smaller, value at any given moment.
And there are other reasons, apart from capping, that can make a refresh token invalid, as stated in the docs linked by @fhtino.

Thus, my recommendation is still to store the more recent refresh token for use in the backend service.

@fhtino
Copy link
Author

fhtino commented Jan 10, 2021

@amanda-tarafa following you suggestion, I have created a very simple demo. A asp.net core web application for authenticating user and calling Drive/Calendar services. And a console-application - simulating a backend daemon - that uses refresh tokens released to web-application and stored in a shared file. (ok, not secure, but this is only a demo). Refresh_tokens intercepted and stored on OnTokenValidated. Hope this could be useful also for other facing my same issues.
Repo: https://github.com/fhtino/google-stuff

@amanda-tarafa
Copy link
Contributor

I'm glad we could help!
I'll close this issue now since it seems you have solved your use case. Of course feel free to leave a comment here if you think otherwise or open new issues for other problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: question Request for information or clarification. Not an issue.
Projects
None yet
Development

No branches or pull requests

4 participants