Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Merged
merged 3 commits into from
Jun 27, 2022
Merged

Conversation

s2lomon
Copy link
Member

@s2lomon s2lomon commented Jun 2, 2022

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.

Is this change a fix, improvement, new feature, refactoring, or other?

new feature

Is this a change to the core query engine, a connector, client library, or the SPI interfaces? (be specific)

it's a change to OAuth2Authenticator

How would you describe this change to a non-technical end user or system administrator?

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:

# Security
* Add refresh-token support to OAuth2

public static TokenPair fromOAuth2Response(Response tokens)
{
requireNonNull(tokens, "tokens is null");
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return new TokenPair(tokens.getAccessToken(), tokens.getRefreshToken());
return accessAndRefreshTokens(tokens.getAccessToken(), tokens.getRefreshToken());

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

Copy link
Member Author

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")));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static TokenPair accessAndRefreshTokens(String accessToken, String refreshToken)
public static TokenPair accessAndRefreshTokens(String accessToken, @Nullable String refreshToken)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it be Nullable ?

Copy link
Member Author

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.

Copy link
Member Author

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new IllegalArgumentException("Token has timed out", ex);
throw new IllegalArgumentException("Token has expired", ex);

private void disableHandler()
{
TestingRedirectHandlerInjector.setRedirectHandler(uri -> {
throw new IllegalStateException("Redirects has been disabled are not allowed");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert

Copy link
Member Author

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
Copy link
Contributor

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

advanceBy()

Copy link
Member

@11xor6 11xor6 left a 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.

Comment on lines +280 to +282
if (nonce.isEmpty()) {
throw new ChallengeFailedException("Missing nonce");
}
Copy link
Member

@11xor6 11xor6 Jun 2, 2022

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

Comment on lines 36 to 38
install(conditionalModule(RefreshTokensConfig.class, config -> config.getSigningAlgorithm().isEllipticCurve() || config.getSigningAlgorithm().isRsa(),
keyPairBinder -> {
configBinder(keyPairBinder).bindConfig(AsymmetricKeyPairConfig.class);
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

@11xor6 11xor6 Jun 7, 2022

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.

Copy link
Member Author

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

Comment on lines 107 to 109
.signWith(signingKey, signatureAlgorithm)
.compressWith(COMPRESSION_CODEC);
return jwt.compact();
Copy link
Member

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).

Copy link
Member

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

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Comment on lines 91 to 92
catch (ExpiredJwtException ex) {
throw new IllegalArgumentException("Token has timed out", ex);
Copy link
Member

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).

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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)
Copy link
Member

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.

Comment on lines 101 to 103
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken());
JwtBuilder jwt = newJwtBuilder()
.addClaims(claims.orElse(Map.of()))
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member

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)
Copy link
Member

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.

Comment on lines 91 to 93
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)))
Copy link
Member

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?

Copy link
Member Author

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.

Copy link
Member

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")));
Copy link
Member

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.

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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.

Copy link
Member Author

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.

dain
dain previously requested changes Jun 3, 2022
Copy link
Member

@dain dain left a 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)) {
Copy link
Member

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.

Copy link
Member Author

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)
Copy link
Member

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)
Copy link
Member

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 ?

Copy link
Member Author

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)
Copy link
Member

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 ?

Copy link
Member Author

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.

Copy link
Member Author

@s2lomon s2lomon left a 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)) {
Copy link
Member Author

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)
Copy link
Member Author

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) {
Copy link
Member Author

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.

Comment on lines 91 to 92
catch (ExpiredJwtException ex) {
throw new IllegalArgumentException("Token has timed out", ex);
Copy link
Member Author

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()))
Copy link
Member Author

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)
Copy link
Member Author

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")));
Copy link
Member Author

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.

@@ -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")
Copy link
Member Author

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

Copy link
Member

@lukasz-walkiewicz lukasz-walkiewicz left a 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;
Copy link
Member

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),
Copy link
Member

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
Copy link
Member

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()
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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)
Copy link
Member

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);

?

Copy link
Member

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.

Copy link
Member Author

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")
Copy link
Member

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

Comment on lines 107 to 109
.signWith(signingKey, signatureAlgorithm)
.compressWith(COMPRESSION_CODEC);
return jwt.compact();
Copy link
Member

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

Copy link
Member Author

@s2lomon s2lomon left a 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.

Comment on lines 91 to 92
catch (ExpiredJwtException ex) {
throw new IllegalArgumentException("Token has timed out", ex);
Copy link
Member Author

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.

Comment on lines 101 to 103
Optional<Map<String, Object>> claims = client.getClaims(tokenPair.getAccessToken());
JwtBuilder jwt = newJwtBuilder()
.addClaims(claims.orElse(Map.of()))
Copy link
Member Author

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.

Comment on lines 107 to 109
.signWith(signingKey, signatureAlgorithm)
.compressWith(COMPRESSION_CODEC);
return jwt.compact();
Copy link
Member Author

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.

Comment on lines 36 to 38
install(conditionalModule(RefreshTokensConfig.class, config -> config.getSigningAlgorithm().isEllipticCurve() || config.getSigningAlgorithm().isRsa(),
keyPairBinder -> {
configBinder(keyPairBinder).bindConfig(AsymmetricKeyPairConfig.class);
Copy link
Member Author

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.

Comment on lines +280 to +282
if (nonce.isEmpty()) {
throw new ChallengeFailedException("Missing nonce");
}
Copy link
Member Author

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()
Copy link
Member Author

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)
Copy link
Member Author

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")));
Copy link
Member Author

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.

Copy link
Member

@11xor6 11xor6 left a 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();
Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member

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()
Copy link
Member

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"))
Copy link
Member

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.

Copy link
Member Author

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)
Copy link
Member

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, fixing.

Copy link
Member

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
Copy link
Member

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

Comment on lines +280 to +282
if (nonce.isEmpty()) {
throw new ChallengeFailedException("Missing nonce");
}
Copy link
Member

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)
Copy link
Member

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.

Comment on lines +280 to +282
if (nonce.isEmpty()) {
throw new ChallengeFailedException("Missing nonce");
}
Copy link
Member

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.

Copy link
Member Author

@s2lomon s2lomon left a 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());
Copy link
Member Author

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.

@Praveen2112 Praveen2112 force-pushed the refresh-token branch 2 times, most recently from 54f0beb to 049deff Compare June 20, 2022 17:23

import static com.google.common.base.Strings.isNullOrEmpty;

public class SecretKeyConfig
Copy link
Member

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;
Copy link
Member

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.

Copy link
Member

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"))
Copy link
Member

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
Copy link
Member

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?

Copy link
Member

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

Copy link
Member

@11xor6 11xor6 left a 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.

@dain dain dismissed their stale review June 21, 2022 22:04

Nik's concerns have been addressed

@Praveen2112
Copy link
Member

@11xor6 , @wendigo Addressed the comments.

@Praveen2112 Praveen2112 force-pushed the refresh-token branch 2 times, most recently from e2ee31e to 1a8d72d Compare June 22, 2022 13:48
throw new IllegalArgumentException("Claims are missing");
}
Map<String, Object> claims = accessTokenClaims.get();
if (claims.containsKey(principalField)) {
Copy link
Member

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(
Copy link
Member

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
Copy link
Member

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: JweTokenSerializerModule

Copy link
Member

@11xor6 11xor6 left a 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.

@Praveen2112 Praveen2112 force-pushed the refresh-token branch 3 times, most recently from a63d897 to a5446b9 Compare June 23, 2022 09:54
return refreshTokenExpiration;
}

@Config("http-server.authentication.oauth2.refresh-tokens.refresh.timeout")
Copy link
Member

@lukasz-walkiewicz lukasz-walkiewicz Jun 23, 2022

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.

@Praveen2112 Praveen2112 force-pushed the refresh-token branch 6 times, most recently from 62c8c0a to 4c07b9f Compare June 24, 2022 14:28
s2lomon and others added 3 commits June 27, 2022 16:15
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]>
@Praveen2112 Praveen2112 merged commit b3d03cd into trinodb:master Jun 27, 2022
@github-actions github-actions bot added this to the 388 milestone Jun 27, 2022
@wendigo
Copy link
Contributor

wendigo commented Jun 27, 2022

πŸŽ‰

@colebow
Copy link
Member

colebow commented Jun 27, 2022

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

@kokosing
Copy link
Member

Yay!! πŸŽ‰

@Praveen2112
Copy link
Member

@colebow I have updated the release notes entry in the PR description and have also linked the documentation PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging this pull request may close these issues.

8 participants