-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Add refresh-token support to OAuth2 #12664
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
Conversation
public static TokenPair fromOAuth2Response(Response tokens) | ||
{ | ||
requireNonNull(tokens, "tokens is null"); | ||
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); | |
return accessAndRefreshTokens(tokens.getAccessToken(), tokens.getRefreshToken()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so I will leave it as it is. accessAndRefreshTokens
suits me better with allowing null on the refreshToken parameter. It just simplifies usage in the authenticator and I don't want to repackage Optionals just to use this method here.
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) | ||
{ | ||
return new TokenPair(accessToken, Optional.of(requireNonNull(refreshToken, "refreshToken is null"))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return new TokenPair(accessToken, Optional.of(requireNonNull(refreshToken, "refreshToken is null"))); | |
return new TokenPair(accessToken, Optional.ofNullable(refreshToken)); |
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); | ||
} | ||
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) | |
public static TokenPair accessAndRefreshTokens(String accessToken, @Nullable String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will it be Nullable ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously not. I will remove it and make it so the caller of this factory method needs to make sure that there is an actual refreshToken to be passed. My mistake, thanks for noticing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I take it back, based on the 11xor6 comments it actually makes sense to have it Nullable and allow such TokenPair to exists, even with refresh_tokens enabled.
@@ -75,4 +75,9 @@ protected abstract Optional<Identity> createIdentity(String token) | |||
throws UserMappingException; | |||
|
|||
protected abstract AuthenticationException needAuthentication(ContainerRequestContext request, String message); | |||
|
|||
protected AuthenticationException needAuthentication(ContainerRequestContext request, String token, String message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
protected AuthenticationException needAuthentication(ContainerRequestContext request, String token, String message) | |
protected AuthenticationException needAuthentication(ContainerRequestContext request, String currentToken, String message) |
claims.get(REFRESH_TOKEN_KEY, String.class)); | ||
} | ||
catch (ExpiredJwtException ex) { | ||
throw new IllegalArgumentException("Token has timed out", ex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw new IllegalArgumentException("Token has timed out", ex); | |
throw new IllegalArgumentException("Token has expired", ex); |
...ests/src/main/java/io/trino/tests/product/jdbc/TestExternalAuthorizerOAuth2RefreshToken.java
Show resolved
Hide resolved
private void disableHandler() | ||
{ | ||
TestingRedirectHandlerInjector.setRedirectHandler(uri -> { | ||
throw new IllegalStateException("Redirects has been disabled are not allowed"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw new IllegalStateException("Redirects has been disabled are not allowed"); | |
throw new UnsupportedOperationException("Redirect has been disabled as it's no longer allowed"); |
@@ -72,7 +72,7 @@ public void extendEnvironment(Environment.Builder builder) | |||
binder.exposePort(hydraConsent, 3000); | |||
|
|||
DockerContainer hydra = new DockerContainer(HYDRA_IMAGE, "hydra") | |||
.withEnv("LOG_LEVEL", "debug") | |||
.withEnv("LOG_LEVEL", "trace") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
revert
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my bad, a leftover from the testing
} | ||
} | ||
|
||
private static class ManualClock |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TestingClock
return currentTime; | ||
} | ||
|
||
public void moveCurrentTime(Duration currentTimeDelta) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
advanceBy()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is only a review of the first commit (excluding tests), I will continue with a follow up review, however there are things that must be addressed for security purposes before allowing this to be merged.
if (nonce.isEmpty()) { | ||
throw new ChallengeFailedException("Missing nonce"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no guarantee that we have a nonce hence why the parameter is Optional
. There are existing code paths which do not provide the OAuth flow with a nonce, by requiring it here those code paths can only result in an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this is a small refactoring of the existing code (required to share code for the refresh token, that doesn't have a nonce). All the paths that are not requiring nonce cookies are already going through some other paths (or are not working). I think that some time ago we've decided to have this initialization url, that sets up the nonce cookie etc. so it should be covered everywhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this function can never be called without a nonce, then please change the signature. However I'm pretty sure not all paths generate a nonce.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right so it's always required for OIDC flows, but never present in Oauth2.0 flows. Still, since the flow is an interface, we have this Optional in a parameter list. Maybe it could be refactored , but I think it would take much time to hide this one parameter and I wouldn't like to do it in this pr, as it would include too many changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what I was missing here. This is specifically the OIDC AuthorizationCodeFlow implementation. I am still worried that there is a way for us to end up here without a nonce (perhaps through the UI?), but if it exists I can't find it. This check now makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will leave it as it is then.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
install(conditionalModule(RefreshTokensConfig.class, config -> config.getSigningAlgorithm().isEllipticCurve() || config.getSigningAlgorithm().isRsa(), | ||
keyPairBinder -> { | ||
configBinder(keyPairBinder).bindConfig(AsymmetricKeyPairConfig.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why are we supporting asymmetric encryption for signing tokens we issue? Is there some need to have a second or third party decode our tokens? It seems to me we issue these tokens to the client and we are the only ones that need to verify the signature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was just easy to be done. The whole purpose of this is to not allow someone to tamper with our Tokens, so being the only one that can sign this token is exactly what we need. We can allow users to setup their own symetric or asymetric keys, but then it's a user option and I feel ok with letting people do this. I don't know, maybe I'm too paranoid with this - would like to know your thoughts.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Asymmetric keys buy us nothing in this case. The only reason to even consider them is to allow a third party or another service to introspect and/or trust the tokens. Given that's the case I see no reason to support them. Additionally this combined with the need to encrypt values within the tokens is problematic as it's computationally expensive and easy to misunderstand how to do the encryption properly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I've removed all the assymetric keys setup, leaving with just symetric one to encrypt the token. It's much easier this way and I guess you are right that it doesn't give us anything that we would actually need.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
.signWith(signingKey, signatureAlgorithm) | ||
.compressWith(COMPRESSION_CODEC); | ||
return jwt.compact(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Signing and compressing a token containing plain text access and refresh tokens is incredibly insecure. Both access tokens and refresh tokens are secrets that have been entrusted to us (Trino) and as such if we're returning them to the client in any form they MUST be encrypted.
The options available are to either encrypt the specific values included in the token or encrypt the token as a whole (which JJWT supports).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, I think using asymetric encryption here pose a security risk without any benefits as Trino is the only recipient of the token. JWE should be a better fit here: https://datatracker.ietf.org/doc/html/rfc7516
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I'm not sure however how this differs from keeping these tokens in the WebBrowser Cookies, as in order to use the RefreshToken you need to have valid client credentials, so it's not that you can just steal it. Our initial idea was that we should secure our token from any kind of tampering with it and that sharing these tokens is "OKish", as it already happens in the WebBrowser. Still - I'm fine with encrypting these claims and then signing and compressing the whole thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a matter of trust, access and refresh tokens are entrusted to Trino, they are not entrusted to the client. Thus giving the user access to the tokens (even if they're their own) breaks our contract with the token issuer (IdP). We don't control the client environment and there's no way for us to ensure or validate that no third-party has access to any storage mechanism the client supports and/or uses, and that is in addition to the possible malicious action of the client/credential holder themselves. Access tokens (and by extension refresh tokens) can grant access to resources which are not directly entrusted to the credential holder. In a way the ability to act on behalf of another user can be more dangerous than what that user could do themselves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I agree, I've not been thinking about the other end of the equation when wanted to protect ourselves from token tampering, so that preventing someone to steal the tokens is as valid as preventing someone to swap these tokens when in communication with us. I'm about to add the WebUI part as well, do you think that we should encrypt these tokens in Cookies there?
In addition, we are not encrypting any token for the accessTokens in WebUI, so maybe we should do this as a followup pr? What's more, maybe it actually make sense to issue these encrypted tokens, even if refreshTokens are not available? - again as a follow up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're sending access tokens unencrypted to the UI then we definitely need to fix that ASAP. If a token (access or refresh) ever leaves our control then it should be encrypted. Also if it's possible we shouldn't even hold on to tokens unless we need them.
catch (ExpiredJwtException ex) { | ||
throw new IllegalArgumentException("Token has timed out", ex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need proper controls around the expiration of these JWT tokens. The expiration of the contained access token is not good enough as when it expires our token would expire and that invalidates the viability of the refresh token. I propose a configurable maximum token lifetime (as the token expiration) coupled with a secondary expiration related to the access token (stored in a separate field and validated as part of authentication).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so this scenario won't happen, as right now we are keeping our token with the expiration of an accessToken
and I'm providing long expirationTimeExtensionSeconds
(it's in seconds because the api accepts only seconds).
This means that the expiration time of token issued by us is accessToken.expiration + expirationTimeExtension
.
Right now refresh will happen only after the accessToken has expired, so the expirationTimeExtension is actually only here to allow refresh to happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand that we implicitly capture when the access token expires, however I'm asking here that it be explicit instead. The current value for the token expiration is good, having it expire some time after the access token itself should expire is good. However I would also like to see the token expiration encoded into the token and used to force a refresh of the token rather than us having to find out the hard way by retrieving the claims associated with the token.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see and I like that this gives us easy possibility to refresh tokens before they timeout.
this.parser = newJwtParserBuilder() | ||
.setSigningKey(verificationKey) | ||
.setClock(this.clock) | ||
.requireIssuer(this.issuer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issuer should most certainly be required and should have a value that uniquely identifies this Trino instance. Otherwise if two Trino instances have the same signing/encryption keys it would be allowed to use a token issued to one on the other.
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.addClaims(claims.orElse(Map.of())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no need to, nor should we, include the claims from the access token. These could contain sensitive values which should not be present in our token. Additionally we will validate the access token on authentication and retrieve these claims then. Thus there's no need to do the extra work here; it makes the token smaller and prevents the possible leakage of sensitive data.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok the reason behind it was to make it available for some network proxies or load balancers that could potentially tap into claims of the accessToken. I would gladly remove it if we think that this is not necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No network proxy or load balancer would even be able to access these tokens as it's all behind TLS/SSL.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So for example in AWS you can have a Gateway, that modifies the requests through some AWS Lambdas. It can also redirect these calls, based on the requests etc.. It's all happening in the DMZ, so no TLS/SSL is involved (at least during the processing itself). It's a little bit farfetched, but just so you know, that this is possible in principle (I've actually seen systems that were doing first layer of authorization in such places). Still - I'm happy to remove these claims.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I don't think we'll need any introspection, if it does come down to that we can reexamine at that time, but I don't think it'll be an issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
@@ -77,4 +83,14 @@ protected AuthenticationException needAuthentication(ContainerRequestContext req | |||
URI tokenUri = request.getUriInfo().getBaseUri().resolve(getTokenUri(authId)); | |||
return new AuthenticationException(message, format("Bearer x_redirect_server=\"%s\", x_token_server=\"%s\"", initiateUri, tokenUri)); | |||
} | |||
|
|||
@Override | |||
protected AuthenticationException needAuthentication(ContainerRequestContext request, String token, String message) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given the new signature of this method is the other needAuthentication
implementation necessary? It seems we would always use this version.
Optional<UUID> refreshProcess = tokenRefresher.refreshToken(tokenPair); | ||
return refreshProcess.map(refreshId -> request.getUriInfo().getBaseUri().resolve(getTokenUri(refreshId))) | ||
.map(tokenUri -> new AuthenticationException(message, format("Bearer x_token_server=\"%s\"", tokenUri))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems unnecessary, we already have the refresh token and no user interaction is required in order acquire a new access (and possible refresh) token. We could easily perform the refresh token flow and return a new JWT token instead of going through all the work of returning this response and making the client do more work.
Perhaps we need to add an additional step to the AbstractBearerAuthenticator
to support this behavior?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so we can't do that I think. I don't believe that any of our client (jdbc/cli/python etc.) is taking the auth response headers and replacing it for the next request call. We do however allowed this Bearer x_token_server
header to contain only x_token_server, so that client would only start polling for a new token to replace the old one, but would not try to open web-browser etc. This should be supported by every client that claims to support OAuth2/ExternalAuthentication protocol and it's the only way we can replace the existing token in these clients. In a way, this is exactly what you are talking about, but executed in the norms of the already existing flow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My bad, I thought this worked differently and after digging into the code I realize that this is the right thing to do. It would be better if the client protocol was cleaner, but this is what we have right now.
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) | ||
{ | ||
return new TokenPair(accessToken, Optional.of(requireNonNull(refreshToken, "refreshToken is null"))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is no guarantee that we will ever be issued a refresh token, it is always at the discretion of the IdP if they issue one, making the assumption that we will always receive one is a bug waiting to happen. It is even such that different users or different circumstances completely out of our control could cause the IdP to not issue a refresh token.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Additionally a successful response will always contain an access token and that should be verified to be non-null.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I would just rather want that to be verified by the caller, so that the caller would use another method like TokenPair.accessToken(). I've added however method to handle Response from OAuth2Client, so this what you are saying should be handled there - I will double check that this is the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the calling code is choosing a method based on an optional field then there should be a single method to handle both cases, there's no real need for the calling code to care if the refresh token is present or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok you are right, It is a bug waiting to happen.
core/trino-main/src/main/java/io/trino/server/security/oauth2/TokenRefresher.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comments from @11xor6 should be addressed
@ConfigSecuritySensitive | ||
public AsymmetricKeyPairConfig setPrivateSigningKeyPath(String keyPath) | ||
{ | ||
if (isNullOrEmpty(keyPath)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: We could throw some configuration error - if it is sent to empty ?
Any issues on using File
instead of String
?
We might also need to add assertion if the file exists.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, we can distinguish null from empty. File should work as well.
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); | ||
} | ||
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will it be Nullable ?
|
||
@Config("http-server.authentication.oauth2.refresh-tokens.signing.algorithm") | ||
@ConfigDescription("Signature algorithm that will be used to secure a token. If no keys will be provided, it will be used as a base to generate adequate keys") | ||
public RefreshTokensConfig setSigningAlgorithm(String algorithm) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since it is an enum
, airlift would handle the conversion right ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but one of the value of the SignatureAlgorithm is NONE. I think that I would like to prevent that from beeing used. (I've forget to add that here) WIll airlift print out all possible values if we use enum here? If not then I can use enum and make sure that it's never NONE.
|
||
@Config("http-server.authentication.oauth2.refresh-tokens.expiration.seconds") | ||
@ConfigDescription("Signature algorithm that will be used to secure a token. If no keys will be provided, it will be used as a base to generate adequate keys") | ||
public RefreshTokensConfig setRefreshTokenExpirationExtensionSeconds(long refreshTokenExpirationExtensionSeconds) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use Duration
here - it would be more configurable ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about the Duration
, but the thing is that I couldn't think of a good way of validating it. This value is later on used as a seconds, so anything lower that that shouldn't be allowed in the config as well. On the other hand, having an option to disable it might be handy as well. Right now, it would require us to proactively refreshing tokens, before they expire, in order for such an option to have sense, which is not yet implemented, but seems to be a natural evolution of such mechanisms.
On the other hand, we could have Duration here with @Min("1s")
and say that it's not possible to disable it completely - even with the proactive refreshing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for quick review guys. Please take a look at my responses.
@ConfigSecuritySensitive | ||
public AsymmetricKeyPairConfig setPrivateSigningKeyPath(String keyPath) | ||
{ | ||
if (isNullOrEmpty(keyPath)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, we can distinguish null from empty. File should work as well.
this.parser = newJwtParserBuilder() | ||
.setSigningKey(verificationKey) | ||
.setClock(this.clock) | ||
.requireIssuer(this.issuer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so I guess, we should have version + something elese. The question is what should it be, as I think tokens should survive coordinator restart (assuming that the signing keys are provided and not generated)
claims.get(ACCESS_TOKEN_KEY, String.class), | ||
claims.get(REFRESH_TOKEN_KEY, String.class)); | ||
} | ||
catch (ExpiredJwtException ex) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes you are right, I will add proper error handling here.
catch (ExpiredJwtException ex) { | ||
throw new IllegalArgumentException("Token has timed out", ex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so this scenario won't happen, as right now we are keeping our token with the expiration of an accessToken
and I'm providing long expirationTimeExtensionSeconds
(it's in seconds because the api accepts only seconds).
This means that the expiration time of token issued by us is accessToken.expiration + expirationTimeExtension
.
Right now refresh will happen only after the accessToken has expired, so the expirationTimeExtension is actually only here to allow refresh to happen.
|
||
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.addClaims(claims.orElse(Map.of())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); | ||
} | ||
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously not. I will remove it and make it so the caller of this factory method needs to make sure that there is an actual refreshToken to be passed. My mistake, thanks for noticing.
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) | ||
{ | ||
return new TokenPair(accessToken, Optional.of(requireNonNull(refreshToken, "refreshToken is null"))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I would just rather want that to be verified by the caller, so that the caller would use another method like TokenPair.accessToken(). I've added however method to handle Response from OAuth2Client, so this what you are saying should be handled there - I will double check that this is the case.
core/trino-main/src/main/java/io/trino/server/security/oauth2/TokenRefresher.java
Outdated
Show resolved
Hide resolved
core/trino-main/src/test/java/io/trino/server/ui/TestWebUi.java
Outdated
Show resolved
Hide resolved
@@ -72,7 +72,7 @@ public void extendEnvironment(Environment.Builder builder) | |||
binder.exposePort(hydraConsent, 3000); | |||
|
|||
DockerContainer hydra = new DockerContainer(HYDRA_IMAGE, "hydra") | |||
.withEnv("LOG_LEVEL", "debug") | |||
.withEnv("LOG_LEVEL", "trace") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
my bad, a leftover from the testing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a quick look and it looks really promissing!
A few initial comments.
|
||
public class AsymmetricKeyPairConfig | ||
{ | ||
private String privateKeyPath; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Woudn't storing both keys in the same file be easier? IIRC it's already the case for SSL in Trino, you can use a file with both.
@PostConstruct | ||
public void validate() | ||
{ | ||
verify((publicKeyPath != null && privateKeyPath != null) || (publicKeyPath == null && privateKeyPath == null), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit
verify(!(publicKeyPath == null ^ privateKeyPath == null));
@Singleton | ||
@Provides | ||
@Inject | ||
@ForTokenRefresher |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't it be easier to just use RefreshTokensConfig
instead of this annotated type?
@@ -220,6 +221,19 @@ public OAuth2Config setUserMappingFile(File userMappingFile) | |||
return this; | |||
} | |||
|
|||
public boolean isEnableRefreshTokens() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't it be inferred from scopes
content? If offline_access
scope is present then refreshing tokens is enabled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about that, but I can't find any clear indication that scopes that enables refresh_token are always offline, or offline_access. I don't find it in the rfc at least. Due to that, I'm thinking it's best to keep it explicitly required to enable refresh tokens and to require additional scopes to be present.
If you know any place that makes it a part of the standard then please share. Without this I wouldn't like to remove this explicit flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The IdP can issue a refresh token independently of the offline_access
scope. The only thing the offline_access
scope is there for is to allow requesting a refresh token that can be used when the user is no longer present for the session (i.e. to do background or offline processing). It's also worth noting that it's up to the IdP's discretion to issue a refresh token and as such you should never expect to receive one. See:
private interface AuthorizationCodeFlow | ||
{ | ||
Request createAuthorizationRequest(String state, URI callbackUri); | ||
|
||
Response getOAuth2Response(String code, URI callbackUri, Optional<String> nonce) | ||
throws ChallengeFailedException; | ||
|
||
Response refreshTokens(String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Response getTokens(String code, URI callbackUri, Optional<String> nonce);
Response getTokens(String refreshToken);
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general I like this, conceptually they are doing the same thing (calling the token
endpoint on the IdP), just with different parameters. However I'm fine with just keeping and overloading the existing name getOAuth2Response
which I think is clearer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would go with resolveTokens
and refreshTokens
as you can't actually call Response getTokens(String code, URI callbackUri, Optional<String> nonce);
multiple times in a row, so having a method name that actually gives us a hint that a possibly non repeatable action is taking place is a better thing to have. Still, let's have it in a subsequent pr.
} | ||
|
||
@Config("http-server.authentication.oauth2.refresh-tokens.signing.algorithm") | ||
@ConfigDescription("Signature algorithm that will be used to secure a token. If no keys will be provided, it will be used as a base to generate adequate keys") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So either keys or signature algorithm? If yes then maybe move to the same file and add validation
.signWith(signingKey, signatureAlgorithm) | ||
.compressWith(COMPRESSION_CODEC); | ||
return jwt.compact(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, I think using asymetric encryption here pose a security risk without any benefits as Trino is the only recipient of the token. JWE should be a better fit here: https://datatracker.ietf.org/doc/html/rfc7516
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry for a bit longer pause, I've had to think over few things here to fit your comments right in. Please take a look again.
catch (ExpiredJwtException ex) { | ||
throw new IllegalArgumentException("Token has timed out", ex); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see and I like that this gives us easy possibility to refresh tokens before they timeout.
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.addClaims(claims.orElse(Map.of())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So for example in AWS you can have a Gateway, that modifies the requests through some AWS Lambdas. It can also redirect these calls, based on the requests etc.. It's all happening in the DMZ, so no TLS/SSL is involved (at least during the processing itself). It's a little bit farfetched, but just so you know, that this is possible in principle (I've actually seen systems that were doing first layer of authorization in such places). Still - I'm happy to remove these claims.
.signWith(signingKey, signatureAlgorithm) | ||
.compressWith(COMPRESSION_CODEC); | ||
return jwt.compact(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I agree, I've not been thinking about the other end of the equation when wanted to protect ourselves from token tampering, so that preventing someone to steal the tokens is as valid as preventing someone to swap these tokens when in communication with us. I'm about to add the WebUI part as well, do you think that we should encrypt these tokens in Cookies there?
In addition, we are not encrypting any token for the accessTokens in WebUI, so maybe we should do this as a followup pr? What's more, maybe it actually make sense to issue these encrypted tokens, even if refreshTokens are not available? - again as a follow up.
install(conditionalModule(RefreshTokensConfig.class, config -> config.getSigningAlgorithm().isEllipticCurve() || config.getSigningAlgorithm().isRsa(), | ||
keyPairBinder -> { | ||
configBinder(keyPairBinder).bindConfig(AsymmetricKeyPairConfig.class); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I've removed all the assymetric keys setup, leaving with just symetric one to encrypt the token. It's much easier this way and I guess you are right that it doesn't give us anything that we would actually need.
if (nonce.isEmpty()) { | ||
throw new ChallengeFailedException("Missing nonce"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right so it's always required for OIDC flows, but never present in Oauth2.0 flows. Still, since the flow is an interface, we have this Optional in a parameter list. Maybe it could be refactored , but I think it would take much time to hide this one parameter and I wouldn't like to do it in this pr, as it would include too many changes.
@@ -220,6 +221,19 @@ public OAuth2Config setUserMappingFile(File userMappingFile) | |||
return this; | |||
} | |||
|
|||
public boolean isEnableRefreshTokens() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about that, but I can't find any clear indication that scopes that enables refresh_token are always offline, or offline_access. I don't find it in the rfc at least. Due to that, I'm thinking it's best to keep it explicitly required to enable refresh tokens and to require additional scopes to be present.
If you know any place that makes it a part of the standard then please share. Without this I wouldn't like to remove this explicit flag.
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); | ||
} | ||
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I take it back, based on the 11xor6 comments it actually makes sense to have it Nullable and allow such TokenPair to exists, even with refresh_tokens enabled.
|
||
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken) | ||
{ | ||
return new TokenPair(accessToken, Optional.of(requireNonNull(refreshToken, "refreshToken is null"))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok you are right, It is a bug waiting to happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally looks better. A few additional comments, but most of the major stuff has been addressed.
private static SecretKey createKey(SecretKeyConfig config) | ||
throws NoSuchAlgorithmException | ||
{ | ||
SecretKey signingKey = config.getSigningKey(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: this isn't a signing key any more, please rename.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct, that's a leftover. Renaming it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
@@ -220,6 +221,19 @@ public OAuth2Config setUserMappingFile(File userMappingFile) | |||
return this; | |||
} | |||
|
|||
public boolean isEnableRefreshTokens() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The IdP can issue a refresh token independently of the offline_access
scope. The only thing the offline_access
scope is there for is to allow requesting a refresh token that can be used when the user is no longer present for the session (i.e. to do background or offline processing). It's also worth noting that it's up to the IdP's discretion to issue a refresh token and as such you should never expect to receive one. See:
Optional<Map<String, Object>> accessTokenClaims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.setExpiration(Date.from(clock.instant().plusMillis(accessTokenExpiration.toMillis()))) | ||
.setSubject(accessTokenClaims.map(claims -> claims.get(SUBJECT).toString()).orElse("Unknown")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should use the principalField
from OAuth2Config
rather than SUBJECT
as that is not necessarily the correct claim; see OAuth2Authenticator
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good point.
|
||
this.parser = newJwtParserBuilder() | ||
.setClock(() -> Date.from(clock.instant())) | ||
.requireIssuer(this.issuer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also require the proper audience.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My bad, fixing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
return new IllegalStateException("Expected refresh token to be present. Please check your identity provider setup, or disable refresh tokens"); | ||
} | ||
|
||
static class ZstdCodec |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's move this to its own class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok
if (nonce.isEmpty()) { | ||
throw new ChallengeFailedException("Missing nonce"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see what I was missing here. This is specifically the OIDC AuthorizationCodeFlow implementation. I am still worried that there is a way for us to end up here without a nonce (perhaps through the UI?), but if it exists I can't find it. This check now makes sense.
private interface AuthorizationCodeFlow | ||
{ | ||
Request createAuthorizationRequest(String state, URI callbackUri); | ||
|
||
Response getOAuth2Response(String code, URI callbackUri, Optional<String> nonce) | ||
throws ChallengeFailedException; | ||
|
||
Response refreshTokens(String refreshToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general I like this, conceptually they are doing the same thing (calling the token
endpoint on the IdP), just with different parameters. However I'm fine with just keeping and overloading the existing name getOAuth2Response
which I think is clearer.
if (nonce.isEmpty()) { | ||
throw new ChallengeFailedException("Missing nonce"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I believe this should be the checked ChallengeFailedException
a missing nonce here does mean that the challenge failed, this isn't a precondition, it's a requirement of the protocol.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've fixed extending issues and I've added a WebUI implementation for refresh tokens.
I've decided to replace existing Cookie with encrypted one, as this implementation seems much easier and just fitting in exiting design, that creating separate Cookie for refresh token etc. (not to mention lack of encryption for the existing accessToken)
Please take a look again.
public static TokenPair fromOAuth2Response(Response tokens) | ||
{ | ||
requireNonNull(tokens, "tokens is null"); | ||
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok so I will leave it as it is. accessAndRefreshTokens
suits me better with allowing null on the refreshToken parameter. It just simplifies usage in the authenticator and I don't want to repackage Optionals just to use this method here.
54f0beb
to
049deff
Compare
core/trino-main/src/main/java/io/trino/server/security/oauth2/OAuth2Service.java
Outdated
Show resolved
Hide resolved
core/trino-main/src/main/java/io/trino/server/security/oauth2/OAuth2Service.java
Outdated
Show resolved
Hide resolved
|
||
import static com.google.common.base.Strings.isNullOrEmpty; | ||
|
||
public class SecretKeyConfig |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Merge this config with RefreshTokensConfig
now that there is only one key mechanism.
|
||
public class SecretKeyConfig | ||
{ | ||
private SecretKey secretKey; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could default this to a random value so that it's configuration would be optional. Really the only reason you might want to set this value is if you wanted tokens to persist across restarts. I can go either way on this one, but I think it might be convenient.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JweSecretKeysProvider
implements this. I'm not sure if implementing it as a part of config object.
Optional<Map<String, Object>> accessTokenClaims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.setExpiration(Date.from(clock.instant().plusMillis(accessTokenExpiration.toMillis()))) | ||
.claim(principalField, accessTokenClaims.map(claims -> claims.get(principalField).toString()).orElse("Unknown")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not having the proper principal field is an error.
|
||
import static java.util.Objects.requireNonNull; | ||
|
||
public class JweSecretKeysProvider |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure we need this class, can it be inlined?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like this is only used in JWETokenSerializer
and we can just move this there.
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken()); | ||
JwtBuilder jwt = newJwtBuilder() | ||
.addClaims(claims.orElse(Map.of())) | ||
.setIssuer(issuer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally looks pretty good, there's still this comment to address, and I've added a few other minor things. I'm happy to walk someone through how to accomplish the change request from the linked comment.
049deff
to
824de55
Compare
e2ee31e
to
1a8d72d
Compare
core/trino-main/src/main/java/io/trino/server/security/oauth2/OAuth2Authenticator.java
Show resolved
Hide resolved
1a8d72d
to
03798ef
Compare
throw new IllegalArgumentException("Claims are missing"); | ||
} | ||
Map<String, Object> claims = accessTokenClaims.get(); | ||
if (claims.containsKey(principalField)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing !
private final AESDecrypter jweDecrypter; | ||
private final String principalField; | ||
|
||
public JWETokenSerializer( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: should be JweTokenSerializer
import static org.assertj.core.api.Assertions.assertThat; | ||
import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
||
public class TestJWETokenSerializer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: TestJweTokenSerializer
|
||
import static io.airlift.configuration.ConfigBinder.configBinder; | ||
|
||
public class JWETokenSerializerModule |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: JweTokenSerializerModule
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I few nits left over, but overall I'm happy with this.
a63d897
to
a5446b9
Compare
core/trino-main/src/main/java/io/trino/server/security/oauth2/JweTokenSerializer.java
Outdated
Show resolved
Hide resolved
core/trino-main/src/main/java/io/trino/server/security/oauth2/JweTokenSerializer.java
Show resolved
Hide resolved
core/trino-main/src/main/java/io/trino/server/security/oauth2/JweTokenSerializer.java
Show resolved
Hide resolved
...rc/main/java/io/trino/tests/product/launcher/env/environment/EnvSinglenodeOauth2Refresh.java
Show resolved
Hide resolved
...ests/src/main/java/io/trino/tests/product/jdbc/TestExternalAuthorizerOAuth2RefreshToken.java
Outdated
Show resolved
Hide resolved
core/trino-main/src/main/java/io/trino/server/security/oauth2/TokenRefresher.java
Outdated
Show resolved
Hide resolved
return refreshTokenExpiration; | ||
} | ||
|
||
@Config("http-server.authentication.oauth2.refresh-tokens.refresh.timeout") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about the description and overall naming convention. Using refresh
and refresh-tokens
for different things might be confusing. Expiration time for refresh token
it's not really expiration time of the refresh token but the token issued by Trino.
core/trino-main/src/main/java/io/trino/server/security/oauth2/OAuth2Service.java
Outdated
Show resolved
Hide resolved
62c8c0a
to
4c07b9f
Compare
It adds refresh tokens support to OAuth2. Whenever refresh-token is issued it's beeing wrapped along with access-token into self issued JWT token and send to the client. Whenever token needs to be refreshed, refresh-token is extracted and used to refresh access-token.
Co-authored-by: aczajkowski <[email protected]>
4c07b9f
to
56ad6c5
Compare
π |
Would we like to mention this change in release notes, and if so, could you propose a release note? Also, does this require documentation on the OAuth2 page? cc @Praveen2112 |
Yay!! π |
@colebow I have updated the release notes entry in the PR description and have also linked the documentation PR. |
Description
It adds refresh tokens support to OAuth2. Whenever refresh-token is issued
it's beeing wrapped along with access-token into self issued JWT token
and send to the client. Whenever token needs to be refreshed, refresh-token
is extracted and used to refresh access-token.
new feature
it's a change to OAuth2Authenticator
Allows to automatically refresh tokens without opening new browser every time a token times out
Related issues, pull requests, and links
Documentation
( ) No documentation is needed.
( ) Sufficient documentation is included in this PR.
( ) Documentation PR is available with #prnumber.
( ) Documentation issue #issuenumber is filed, and can be handled later.
Release notes
( ) No release notes entries required.
(x) Release notes entries required with the following suggested text: