From dc871c18d1bbb541e6e5748401c9a14a933a8a68 Mon Sep 17 00:00:00 2001 From: Andreas Falk Date: Sat, 11 Sep 2021 21:06:32 +0200 Subject: [PATCH 1/2] initial implementation of token exchange (only access tokens) --- .../authorizationserver/DataInitializer.java | 10 + .../oauth/common/TokenType.java | 50 ++ .../oauth/endpoint/token/TokenEndpoint.java | 7 +- .../endpoint/token/TokenEndpointHelper.java | 9 + .../token/TokenExchangeEndpointService.java | 491 ++++++++++++++++++ .../endpoint/token/resource/TokenRequest.java | 136 ++++- .../token/resource/TokenResponse.java | 44 +- .../token/store/model/JsonWebToken.java | 4 + .../TokenEndpointIntegrationTest.java | 2 +- .../TokenExchangeEndpointIntegrationTest.java | 303 +++++++++++ 10 files changed, 1035 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/example/authorizationserver/oauth/common/TokenType.java create mode 100644 src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenExchangeEndpointService.java create mode 100644 src/test/java/com/example/authorizationserver/oauth/endpoint/TokenExchangeEndpointIntegrationTest.java diff --git a/src/main/java/com/example/authorizationserver/DataInitializer.java b/src/main/java/com/example/authorizationserver/DataInitializer.java index eec9ca9..a35efaf 100644 --- a/src/main/java/com/example/authorizationserver/DataInitializer.java +++ b/src/main/java/com/example/authorizationserver/DataInitializer.java @@ -219,6 +219,16 @@ private void createClients() { false, AccessTokenFormat.OPAQUE, Set.of(GrantType.AUTHORIZATION_CODE), + Collections.singleton( + "http://localhost:8080/demo-client/login/oauth2/code/demo"), + Collections.singleton("*")), + new RegisteredClient( + UUID.randomUUID(), + "token-exchange", + passwordEncoder.encode("demo"), + true, + AccessTokenFormat.JWT, + Set.of(GrantType.TOKEN_EXCHANGE), Collections.singleton( "http://localhost:8080/demo-client/login/oauth2/code/demo"), Collections.singleton("*"))) diff --git a/src/main/java/com/example/authorizationserver/oauth/common/TokenType.java b/src/main/java/com/example/authorizationserver/oauth/common/TokenType.java new file mode 100644 index 0000000..cfd1361 --- /dev/null +++ b/src/main/java/com/example/authorizationserver/oauth/common/TokenType.java @@ -0,0 +1,50 @@ +package com.example.authorizationserver.oauth.common; + +public enum TokenType { + + // Indicates that the token is an OAuth 2.0 access token + ACCESS_TOKEN("urn:ietf:params:oauth:token-type:access_token"), + + // Indicates that the token is an OAuth 2.0 refresh token. + REFRESH_TOKEN("urn:ietf:params:oauth:token-type:refresh_token"), + + // Indicates that the token is an ID Token as defined in OpenID.Core. + ID_TOKEN("urn:ietf:params:oauth:token-type:id_token"), + + // Indicates that the token is a base64url-encoded SAML 1.1 assertion. + SAML11_TOKEN("urn:ietf:params:oauth:token-type:saml1"), + + // Indicates that the token is a base64url-encoded SAML 2.0 assertion. + SAML2_TOKEN("urn:ietf:params:oauth:token-type:saml2"), + + // Indicated that the token is a JSON web token. + JWT_TOKEN("urn:ietf:params:oauth:token-type:jwt"); + + private final String identifier; + + TokenType(String identifier) { + this.identifier = identifier; + } + + public String getIdentifier() { + return identifier; + } + + public static TokenType getTokenTypeForIdentifier(String identifier) { + if (ACCESS_TOKEN.getIdentifier().equals(identifier)) { + return ACCESS_TOKEN; + } else if (REFRESH_TOKEN.getIdentifier().equals(identifier)) { + return REFRESH_TOKEN; + } else if (ID_TOKEN.getIdentifier().equals(identifier)) { + return ID_TOKEN; + } else if (SAML11_TOKEN.getIdentifier().equals(identifier)) { + return SAML11_TOKEN; + } else if (SAML2_TOKEN.getIdentifier().equals(identifier)) { + return SAML2_TOKEN; + } else if (JWT_TOKEN.getIdentifier().equals(identifier)) { + return JWT_TOKEN; + } else { + throw new IllegalArgumentException("Invalid token type " + identifier); + } + } +} diff --git a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpoint.java b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpoint.java index 54da356..1fae692 100644 --- a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpoint.java +++ b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpoint.java @@ -29,16 +29,18 @@ public class TokenEndpoint { private final PasswordTokenEndpointService passwordTokenEndpointService; private final RefreshTokenEndpointService refreshTokenEndpointService; private final AuthorizationCodeTokenEndpointService authorizationCodeTokenEndpointService; + private final TokenExchangeEndpointService tokenExchangeEndpointService; public TokenEndpoint( ClientCredentialsTokenEndpointService clientCredentialsTokenEndpointService, PasswordTokenEndpointService passwordTokenEndpointService, RefreshTokenEndpointService refreshTokenEndpointService, - AuthorizationCodeTokenEndpointService authorizationCodeTokenEndpointService) { + AuthorizationCodeTokenEndpointService authorizationCodeTokenEndpointService, TokenExchangeEndpointService tokenExchangeEndpointService) { this.clientCredentialsTokenEndpointService = clientCredentialsTokenEndpointService; this.passwordTokenEndpointService = passwordTokenEndpointService; this.refreshTokenEndpointService = refreshTokenEndpointService; this.authorizationCodeTokenEndpointService = authorizationCodeTokenEndpointService; + this.tokenExchangeEndpointService = tokenExchangeEndpointService; } @PostMapping @@ -63,8 +65,7 @@ public ResponseEntity getToken( return refreshTokenEndpointService.getTokenResponseForRefreshToken( authorizationHeader, tokenRequest); } else if (tokenRequest.getGrant_type().equalsIgnoreCase(GrantType.TOKEN_EXCHANGE.getGrant())) { - LOG.warn("Requested grant type for 'Token Exchange' is not yet supported"); - return ResponseEntity.badRequest().body(new TokenResponse("unsupported_grant_type")); + return tokenExchangeEndpointService.getTokenResponseForTokenExchange(authorizationHeader, tokenRequest); } else { LOG.warn("Requested grant type [{}] is unsupported", tokenRequest.getGrant_type()); return ResponseEntity.badRequest().body(new TokenResponse("unsupported_grant_type")); diff --git a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpointHelper.java b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpointHelper.java index 1280c34..ee3d9e0 100644 --- a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpointHelper.java +++ b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenEndpointHelper.java @@ -37,4 +37,13 @@ static ResponseEntity reportInvalidClientError() { static ResponseEntity reportInvalidGrantError() { return ResponseEntity.badRequest().body(new TokenResponse("invalid_grant")); } + + static ResponseEntity reportInvalidRequestError() { + return ResponseEntity.badRequest().body(new TokenResponse("invalid_request")); + } + + static ResponseEntity reportInvalidTargetError() { + return ResponseEntity.badRequest().body(new TokenResponse("invalid_target")); + } + } diff --git a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenExchangeEndpointService.java b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenExchangeEndpointService.java new file mode 100644 index 0000000..6ec5d1d --- /dev/null +++ b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/TokenExchangeEndpointService.java @@ -0,0 +1,491 @@ +package com.example.authorizationserver.oauth.endpoint.token; + +import com.example.authorizationserver.config.AuthorizationServerConfigurationProperties; +import com.example.authorizationserver.oauth.client.model.AccessTokenFormat; +import com.example.authorizationserver.oauth.client.model.RegisteredClient; +import com.example.authorizationserver.oauth.common.ClientCredentials; +import com.example.authorizationserver.oauth.common.GrantType; +import com.example.authorizationserver.oauth.common.TokenType; +import com.example.authorizationserver.oauth.endpoint.token.resource.TokenRequest; +import com.example.authorizationserver.oauth.endpoint.token.resource.TokenResponse; +import com.example.authorizationserver.oidc.common.Scope; +import com.example.authorizationserver.scim.model.ScimUserEntity; +import com.example.authorizationserver.scim.service.ScimService; +import com.example.authorizationserver.security.client.RegisteredClientAuthenticationService; +import com.example.authorizationserver.token.jwt.JsonWebTokenService; +import com.example.authorizationserver.token.store.TokenService; +import com.example.authorizationserver.token.store.model.JsonWebToken; +import com.example.authorizationserver.token.store.model.OpaqueToken; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jwt.JWTClaimsSet; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Service; + +import java.text.ParseException; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +import static com.example.authorizationserver.oauth.endpoint.token.resource.TokenResponse.BEARER_TOKEN_TYPE; + +@Service +public class TokenExchangeEndpointService { + private static final Logger LOG = LoggerFactory.getLogger(TokenExchangeEndpointService.class); + + private final TokenService tokenService; + private final ScimService scimService; + private final AuthorizationServerConfigurationProperties authorizationServerProperties; + private final RegisteredClientAuthenticationService registeredClientAuthenticationService; + private final JsonWebTokenService jsonWebTokenService; + + public TokenExchangeEndpointService( + TokenService tokenService, + ScimService scimService, AuthorizationServerConfigurationProperties authorizationServerProperties, + RegisteredClientAuthenticationService registeredClientAuthenticationService, JsonWebTokenService jsonWebTokenService) { + this.tokenService = tokenService; + this.scimService = scimService; + this.authorizationServerProperties = authorizationServerProperties; + this.registeredClientAuthenticationService = registeredClientAuthenticationService; + this.jsonWebTokenService = jsonWebTokenService; + } + + /** + * ------------------------- Exchanging a Token + * + *

The client makes a token exchange request to the token endpoint with an extension grant type using the HTTP POST method. + * The following parameters are included in the HTTP request entity-body using the application/x-www-form-urlencoded format per + * Appendix B with a character encoding of UTF-8 in the HTTP request entity-body: + * + *

grant_type REQUIRED. Value MUST be set to "urn:ietf:params:oauth:grant-type:token-exchange". + * refresh_token REQUIRED. The refresh token issued to the client. scope OPTIONAL. The scope of the access request as + * described by Section 3.3. The requested scope MUST NOT include any scope not originally granted + * by the resource owner, and if omitted is treated as equal to the scope originally granted by + * the resource owner. + */ + public ResponseEntity getTokenResponseForTokenExchange( + String authorizationHeader, TokenRequest tokenRequest) { + + LOG.debug("Exchange token for given token with [{}]", tokenRequest); + + ClientCredentials clientCredentials = + TokenEndpointHelper.retrieveClientCredentials(authorizationHeader, tokenRequest); + + if (clientCredentials == null) { + LOG.debug("No client credentials provided"); + return TokenEndpointHelper.reportInvalidClientError(); + } + + Duration accessTokenLifetime = authorizationServerProperties.getAccessToken().getLifetime(); + Duration idTokenLifetime = authorizationServerProperties.getIdToken().getLifetime(); + Duration refreshTokenLifetime = authorizationServerProperties.getRefreshToken().getLifetime(); + + RegisteredClient registeredClient; + + try { + registeredClient = + registeredClientAuthenticationService.authenticate( + clientCredentials.getClientId(), clientCredentials.getClientSecret()); + + } catch (AuthenticationException ex) { + return TokenEndpointHelper.reportInvalidClientError(); + } + + if (registeredClient.getGrantTypes().contains(GrantType.TOKEN_EXCHANGE)) { + TokenType requestedTokenType = TokenType.ACCESS_TOKEN; + TokenType subjectTokenType; + if (tokenRequest.getSubject_token_type() != null) { + try { + subjectTokenType = TokenType.getTokenTypeForIdentifier(tokenRequest.getSubject_token_type()); + } catch (IllegalArgumentException ex) { + LOG.warn("Token exchange is not valid for subject token type [{}]", tokenRequest.getSubject_token_type()); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } else { + LOG.warn("Required parameter [subject_token_type] is missing in request"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + if (tokenRequest.getRequested_token_type() != null) { + try { + requestedTokenType = TokenType.getTokenTypeForIdentifier(tokenRequest.getRequested_token_type()); + } catch (IllegalArgumentException ex) { + LOG.warn("Token exchange is not valid for requested token type [{}]", tokenRequest.getRequested_token_type()); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + if (!(TokenType.ACCESS_TOKEN.equals(requestedTokenType) || TokenType.ID_TOKEN.equals(requestedTokenType))) { + LOG.warn("Token exchange is not supported for requested token type [{}]", tokenRequest.getRequested_token_type()); + return TokenEndpointHelper.reportInvalidRequestError(); + } + + if (TokenType.REFRESH_TOKEN.equals(subjectTokenType)) { + return exchangeRefreshToken(tokenRequest, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, registeredClient, requestedTokenType); + } else if (TokenType.JWT_TOKEN.equals(subjectTokenType)) { + return exchangeJWT(tokenRequest, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, registeredClient, subjectTokenType, requestedTokenType); + } else if (TokenType.ACCESS_TOKEN.equals(subjectTokenType)) { + return exchangeAccessToken(tokenRequest, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, registeredClient, subjectTokenType, requestedTokenType); + } else if (TokenType.ID_TOKEN.equals(subjectTokenType)) { + return exchangeIDToken(tokenRequest, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, registeredClient, subjectTokenType); + } else { + LOG.warn("Unsupported subject token type {}", subjectTokenType); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } else { + return TokenEndpointHelper.reportUnauthorizedClientError(); + } + } + + private ResponseEntity exchangeJWT( + TokenRequest tokenRequest, + Duration accessTokenLifetime, + Duration idTokenLifetime, + Duration refreshTokenLifetime, + RegisteredClient registeredClient, + TokenType subjectTokenType, + TokenType requestedTokenType) { + JsonWebToken jsonWebToken = tokenService.findJsonWebToken(tokenRequest.getSubject_token()); + if (jsonWebToken != null) { + if (isNotValidForSubjectType(jsonWebToken, subjectTokenType)) { + LOG.warn("Type of given token is not valid for given subject token type {}", tokenRequest.getSubject_token_type()); + return TokenEndpointHelper.reportInvalidRequestError(); + } + Set scopes = new HashSet<>(); + if (StringUtils.isNotBlank(tokenRequest.getScope())) { + scopes = new HashSet<>(Arrays.asList(tokenRequest.getScope().split(" "))); + scopes = scopes.stream().map(String::toUpperCase).collect(Collectors.toUnmodifiableSet()); + } + + try { + JWTClaimsSet jwtClaimsSet = + jsonWebTokenService.parseAndValidateToken(jsonWebToken.getValue()); + String subject = jwtClaimsSet.getSubject(); + String ctx = jwtClaimsSet.getStringClaim("ctx"); + if (TokenService.ANONYMOUS_TOKEN.equals(ctx)) { + + LOG.info( + "Creating anonymous token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createAnonymousTokenResponse(tokenRequest, registeredClient, scopes, accessTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + Optional authenticatedUser = + scimService.findUserByIdentifier(UUID.fromString(subject)); + if (authenticatedUser.isPresent()) { + + LOG.info( + "Creating personalized token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createPersonalizedTokenResponse(tokenRequest, registeredClient, authenticatedUser.get(), scopes, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + + return ResponseEntity.ok(tokenResponse); + } else { + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + } catch (ParseException | JOSEException e) { + LOG.warn("Subject token is an invalid JWT"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } else { + LOG.warn("Subject token must be a JWT"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + + private ResponseEntity exchangeAccessToken( + TokenRequest tokenRequest, + Duration accessTokenLifetime, + Duration idTokenLifetime, + Duration refreshTokenLifetime, + RegisteredClient registeredClient, + TokenType subjectTokenType, + TokenType requestedTokenType) { + Set scopes = new HashSet<>(); + if (StringUtils.isNotBlank(tokenRequest.getScope())) { + scopes = new HashSet<>(Arrays.asList(tokenRequest.getScope().split(" "))); + scopes = scopes.stream().map(String::toUpperCase).collect(Collectors.toUnmodifiableSet()); + } + + JsonWebToken jsonWebToken = tokenService.findJsonWebToken(tokenRequest.getSubject_token()); + if (jsonWebToken != null) { + if (isNotValidForSubjectType(jsonWebToken, subjectTokenType)) { + LOG.warn("Type of given token is not valid for given subject token type {}", tokenRequest.getSubject_token_type()); + return TokenEndpointHelper.reportInvalidRequestError(); + } + + try { + JWTClaimsSet jwtClaimsSet = + jsonWebTokenService.parseAndValidateToken(jsonWebToken.getValue()); + String subject = jwtClaimsSet.getSubject(); + String ctx = jwtClaimsSet.getStringClaim("ctx"); + if (TokenService.ANONYMOUS_TOKEN.equals(ctx)) { + + LOG.info( + "Creating anonymous token response for token exchange with client [{}]", + registeredClient.getClientId()); + TokenResponse tokenResponse = createAnonymousTokenResponse(tokenRequest, registeredClient, scopes, accessTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + Optional authenticatedUser = + scimService.findUserByIdentifier(UUID.fromString(subject)); + if (authenticatedUser.isPresent()) { + + LOG.info( + "Creating personalized token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createPersonalizedTokenResponse(tokenRequest, registeredClient, authenticatedUser.get(), scopes, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + } catch (ParseException | JOSEException e) { + LOG.warn("Subject token is an invalid JWT"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } else { + LOG.debug("Subject token is opaque"); + OpaqueToken opaqueAccessToken = tokenService.findOpaqueAccessToken(tokenRequest.getSubject_token()); + if (opaqueAccessToken != null) { + opaqueAccessToken.validate(); + String subject = opaqueAccessToken.getSubject(); + if (TokenService.ANONYMOUS_TOKEN.equals(subject)) { + + LOG.info( + "Creating anonymous token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createAnonymousTokenResponse(tokenRequest, registeredClient, scopes, accessTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + + return ResponseEntity.ok(tokenResponse); + } else { + Optional authenticatedUser = + scimService.findUserByIdentifier(UUID.fromString(opaqueAccessToken.getSubject())); + if (authenticatedUser.isPresent()) { + + LOG.info( + "Creating personalized token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createPersonalizedTokenResponse(tokenRequest, registeredClient, authenticatedUser.get(), scopes, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + + return ResponseEntity.ok(tokenResponse); + } else { + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + } else { + LOG.warn("Token is neither a valid JWT nor of opaque type"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + } + + private ResponseEntity exchangeIDToken( + TokenRequest tokenRequest, + Duration accessTokenLifetime, + Duration idTokenLifetime, + Duration refreshTokenLifetime, + RegisteredClient registeredClient, + TokenType requestedTokenType) { + Set scopes = new HashSet<>(); + if (StringUtils.isNotBlank(tokenRequest.getScope())) { + scopes = new HashSet<>(Arrays.asList(tokenRequest.getScope().split(" "))); + scopes = scopes.stream().map(String::toUpperCase).collect(Collectors.toUnmodifiableSet()); + } + + JsonWebToken jsonWebToken = tokenService.findJsonWebIdToken(tokenRequest.getSubject_token()); + if (jsonWebToken != null) { + try { + JWTClaimsSet jwtClaimsSet = + jsonWebTokenService.parseAndValidateToken(jsonWebToken.getValue()); + String subject = jwtClaimsSet.getSubject(); + String ctx = jwtClaimsSet.getStringClaim("ctx"); + if (TokenService.ANONYMOUS_TOKEN.equals(ctx)) { + + LOG.info( + "Creating anonymous token response for token exchange with client [{}]", + registeredClient.getClientId()); + TokenResponse tokenResponse = createAnonymousTokenResponse(tokenRequest, registeredClient, scopes, accessTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + Optional authenticatedUser = + scimService.findUserByIdentifier(UUID.fromString(subject)); + if (authenticatedUser.isPresent()) { + + LOG.info( + "Creating personalized token response for token exchange with client [{}]", + registeredClient.getClientId()); + + TokenResponse tokenResponse = createPersonalizedTokenResponse(tokenRequest, registeredClient, authenticatedUser.get(), scopes, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.debug( + "Token response for token exchange [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + } catch (ParseException | JOSEException e) { + LOG.warn("Subject token is an invalid JWT"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } else { + LOG.warn("Subject token is not a valid ID Token"); + return TokenEndpointHelper.reportInvalidRequestError(); + } + } + + private ResponseEntity exchangeRefreshToken( + TokenRequest tokenRequest, + Duration accessTokenLifetime, + Duration idTokenLifetime, + Duration refreshTokenLifetime, + RegisteredClient registeredClient, + TokenType requestedTokenType) { + OpaqueToken opaqueWebToken = tokenService.findOpaqueToken(tokenRequest.getSubject_token()); + if (opaqueWebToken != null && opaqueWebToken.isRefreshToken()) { + opaqueWebToken.validate(); + + Set scopes = new HashSet<>(); + if (StringUtils.isNotBlank(tokenRequest.getScope())) { + scopes = new HashSet<>(Arrays.asList(tokenRequest.getScope().split(" "))); + scopes = scopes.stream().map(String::toUpperCase).collect(Collectors.toUnmodifiableSet()); + } + + String subject = opaqueWebToken.getSubject(); + if (TokenService.ANONYMOUS_TOKEN.equals(subject)) { + + LOG.info( + "Creating anonymous token response for refresh token with client [{}]", + tokenRequest.getClient_id()); + + TokenResponse tokenResponse = createAnonymousTokenResponse(tokenRequest, registeredClient, scopes, accessTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.info( + "Token response for refresh token [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } else { + Optional authenticatedUser = + scimService.findUserByIdentifier(UUID.fromString(opaqueWebToken.getSubject())); + if (authenticatedUser.isPresent()) { + + LOG.info( + "Creating personalized token response for refresh token with client [{}]", + tokenRequest.getClient_id()); + + TokenResponse tokenResponse = createPersonalizedTokenResponse(tokenRequest, registeredClient, authenticatedUser.get(), scopes, accessTokenLifetime, idTokenLifetime, refreshTokenLifetime, requestedTokenType); + LOG.info( + "Token response for refresh token [{}]", tokenResponse); + return ResponseEntity.ok(tokenResponse); + } + } + tokenService.remove(opaqueWebToken); + } + return TokenEndpointHelper.reportInvalidClientError(); + } + + private boolean isNotValidForSubjectType(JsonWebToken jsonWebToken, TokenType subjectTokenType) { + boolean isValid = false; + switch(subjectTokenType) { + case ID_TOKEN: + isValid = jsonWebToken.isIdToken(); + break; + case ACCESS_TOKEN: + isValid = jsonWebToken.isAccessToken(); + break; + case JWT_TOKEN: + isValid = true; + break; + case SAML11_TOKEN: + case SAML2_TOKEN: + case REFRESH_TOKEN: + default: + break; + } + return !isValid; + } + + private TokenResponse createAnonymousTokenResponse( + TokenRequest tokenRequest, + RegisteredClient registeredClient, + Set scopes, + Duration accessTokenLifetime, + Duration refreshTokenLifetime, + TokenType requestedTokenType) { + return new TokenResponse( + AccessTokenFormat.JWT.equals(registeredClient.getAccessTokenFormat()) + ? tokenService + .createAnonymousJwtAccessToken( + registeredClient.getClientId(), scopes, accessTokenLifetime) + .getValue() + : tokenService + .createAnonymousOpaqueAccessToken( + registeredClient.getClientId(), scopes, accessTokenLifetime) + .getValue(), + tokenService + .createAnonymousRefreshToken( + registeredClient.getClientId(), scopes, refreshTokenLifetime) + .getValue(), + accessTokenLifetime.toSeconds(), + null, + BEARER_TOKEN_TYPE, requestedTokenType.getIdentifier(), tokenRequest.getScope()); + } + + private TokenResponse createPersonalizedTokenResponse( + TokenRequest tokenRequest, + RegisteredClient registeredClient, + ScimUserEntity authenticatedUser, + Set scopes, + Duration accessTokenLifetime, + Duration idTokenLifetime, + Duration refreshTokenLifetime, + TokenType requestedTokenType) { + return new TokenResponse( + AccessTokenFormat.JWT.equals(registeredClient.getAccessTokenFormat()) + ? tokenService + .createPersonalizedJwtAccessToken( + authenticatedUser, + registeredClient.getClientId(), + null, + scopes, + accessTokenLifetime) + .getValue() + : tokenService + .createPersonalizedOpaqueAccessToken( + authenticatedUser, + registeredClient.getClientId(), + scopes, + accessTokenLifetime) + .getValue(), + tokenService + .createPersonalizedRefreshToken( + registeredClient.getClientId(), + authenticatedUser, + scopes, + refreshTokenLifetime) + .getValue(), + accessTokenLifetime.toSeconds(), + scopes.contains(Scope.OPENID.name()) ? + tokenService.createIdToken(authenticatedUser, registeredClient.getClientId(), null, scopes, idTokenLifetime).getValue() : null, + BEARER_TOKEN_TYPE, requestedTokenType.getIdentifier(), tokenRequest.getScope()); + } +} diff --git a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenRequest.java b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenRequest.java index 3bbcb08..c077332 100644 --- a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenRequest.java +++ b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenRequest.java @@ -1,14 +1,14 @@ package com.example.authorizationserver.oauth.endpoint.token.resource; import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; import java.net.URI; /** * Token Request as specified by: * - *

OAuth 2.0 (https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.3) OpenID Connect 1.0 - * (https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest) + *

OAuth 2.0 (https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.3). + * OpenID Connect 1.0 (https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest) + * and OAuth 2.0 Token Exchange (https://www.rfc-editor.org/rfc/rfc8693.html#section-2.1). */ public class TokenRequest { @@ -46,39 +46,95 @@ public class TokenRequest { /** * The resource owner username REQUIRED if grant type is {@link - * com.example.authorizationserver.oauth.common.GrantType#PASSWORD} + * com.example.authorizationserver.oauth.common.GrantType#PASSWORD}. */ private final String username; /** * The resource owner password. REQUIRED if grant type is {@link - * com.example.authorizationserver.oauth.common.GrantType#PASSWORD} + * com.example.authorizationserver.oauth.common.GrantType#PASSWORD}. */ private final String password; /** * The refresh token issued to the client. REQUIRED if grant type is {@link - * com.example.authorizationserver.oauth.common.GrantType#REFRESH_TOKEN} + * com.example.authorizationserver.oauth.common.GrantType#REFRESH_TOKEN}. */ private final String refresh_token; + /** + * A security token that represents the identity of the party on behalf of + * whom the request is being made. REQUIRED if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String subject_token; + + /** + * An identifier, as described in Section 3, that indicates the type of the security + * token in the subject_token parameter. REQUIRED if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String subject_token_type; + /** * The scope of the access request. OPTIONAL if grant type is {@link - * com.example.authorizationserver.oauth.common.GrantType#CLIENT_CREDENTIALS} + * com.example.authorizationserver.oauth.common.GrantType#CLIENT_CREDENTIALS}. */ private final String scope; + /** + * A URI that indicates the target service or resource where the client + * intends to use the requested security token. OPTIONAL if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String resource; + + /** + * The logical name of the target service where the client intends to + * use the requested security token. OPTIONAL if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String audience; + + /** + * An identifier, as described in Section 3, for the type of the requested + * security token. OPTIONAL if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String requested_token_type; + + /** + * A security token that represents the identity of the acting party. OPTIONAL if grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String actor_token; + + /** + * An identifier, as described in Section 3, that indicates the type of the security + * token in the actor_token parameter. REQUIRED when actor_token parameter is present and grant type is + * {@link com.example.authorizationserver.oauth.common.GrantType#TOKEN_EXCHANGE}. + */ + private final String actor_token_type; + + public TokenRequest( - @NotBlank String grant_type, - @NotBlank String code, - @NotNull URI redirect_uri, + String grant_type, + String code, + URI redirect_uri, String client_id, String client_secret, String code_verifier, String username, String password, String refresh_token, - String scope) { + String subject_token, + String subject_token_type, + String scope, + String resource, + String audience, + String requested_token_type, + String actor_token, + String actor_token_type) { this.grant_type = grant_type; this.code = code; this.redirect_uri = redirect_uri; @@ -88,7 +144,14 @@ public TokenRequest( this.username = username; this.password = password; this.refresh_token = refresh_token; + this.subject_token = subject_token; + this.subject_token_type = subject_token_type; this.scope = scope; + this.resource = resource; + this.audience = audience; + this.requested_token_type = requested_token_type; + this.actor_token = actor_token; + this.actor_token_type = actor_token_type; } public String getGrant_type() { @@ -131,6 +194,34 @@ public String getScope() { return scope; } + public String getSubject_token() { + return subject_token; + } + + public String getSubject_token_type() { + return subject_token_type; + } + + public String getResource() { + return resource; + } + + public String getAudience() { + return audience; + } + + public String getRequested_token_type() { + return requested_token_type; + } + + public String getActor_token() { + return actor_token; + } + + public String getActor_token_type() { + return actor_token_type; + } + @Override public String toString() { return "TokenRequest{" @@ -140,7 +231,7 @@ public String toString() { + ", code='" + code + '\'' - + ", redirect_uri=" + + ", redirect_uri=" + redirect_uri + ", client_id='" + client_id @@ -155,6 +246,27 @@ public String toString() { + ", scope='" + scope + '\'' + + ", resource='" + + resource + + '\'' + + ", audience='" + + audience + + '\'' + + ", requested_token_type='" + + requested_token_type + + '\'' + + ", subject_token='" + + subject_token + + '\'' + + ", subject_token_type='" + + subject_token_type + + '\'' + + ", actor_token='" + + actor_token + + '\'' + + ", actor_token_type='" + + actor_token_type + + '\'' + ", username='" + username + '\'' diff --git a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenResponse.java b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenResponse.java index 4a5411e..63ae099 100644 --- a/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenResponse.java +++ b/src/main/java/com/example/authorizationserver/oauth/endpoint/token/resource/TokenResponse.java @@ -1,5 +1,7 @@ package com.example.authorizationserver.oauth.endpoint.token.resource; +import com.fasterxml.jackson.annotation.JsonCreator; + /** * Token Response as specified by: * @@ -12,22 +14,38 @@ public class TokenResponse { private String access_token; private String token_type; + private String issued_token_type; private String refresh_token; + private String scope; private long expires_in; private String id_token; private String error; public TokenResponse( - String access_token, - String refresh_token, - long expires_in, - String id_token, - String token_type) { + String access_token, + String refresh_token, + long expires_in, + String id_token, + String token_type) { + this(access_token, refresh_token, expires_in, id_token, token_type, null, null); + } + + @JsonCreator + public TokenResponse( + String access_token, + String refresh_token, + long expires_in, + String id_token, + String token_type, + String issued_token_type, + String scope) { this.access_token = access_token; this.refresh_token = refresh_token; this.expires_in = expires_in; this.id_token = id_token; this.token_type = token_type; + this.issued_token_type = issued_token_type; + this.scope = scope; } public TokenResponse(String error) { @@ -74,6 +92,22 @@ public void setId_token(String id_token) { this.id_token = id_token; } + public String getIssued_token_type() { + return issued_token_type; + } + + public void setIssued_token_type(String issued_token_type) { + this.issued_token_type = issued_token_type; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + public String getError() { return error; } diff --git a/src/main/java/com/example/authorizationserver/token/store/model/JsonWebToken.java b/src/main/java/com/example/authorizationserver/token/store/model/JsonWebToken.java index ee4007a..ad5c41f 100644 --- a/src/main/java/com/example/authorizationserver/token/store/model/JsonWebToken.java +++ b/src/main/java/com/example/authorizationserver/token/store/model/JsonWebToken.java @@ -15,6 +15,10 @@ public boolean isAccessToken() { return accessToken; } + public boolean isIdToken() { + return !accessToken; + } + public void setAccessToken(boolean idToken) { this.accessToken = idToken; } diff --git a/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenEndpointIntegrationTest.java b/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenEndpointIntegrationTest.java index 2a198a9..384650e 100644 --- a/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenEndpointIntegrationTest.java +++ b/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenEndpointIntegrationTest.java @@ -398,7 +398,7 @@ void getTokenForPersonalizedRefreshTokenGrantSuccess() { assertThat(tokenResponse.getError()).describedAs("error must not be present").isNull(); } - @ValueSource(strings = {"urn:ietf:params:oauth:grant-type:token-exchange", "invalid"}) + @ValueSource(strings = {"urn:ietf:params:oauth:grant-type:saml2-bearer", "invalid"}) @ParameterizedTest void getTokenForTokenExchangeGrantNotSupportedFail(String grant) { diff --git a/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenExchangeEndpointIntegrationTest.java b/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenExchangeEndpointIntegrationTest.java new file mode 100644 index 0000000..d737f2b --- /dev/null +++ b/src/test/java/com/example/authorizationserver/oauth/endpoint/TokenExchangeEndpointIntegrationTest.java @@ -0,0 +1,303 @@ +package com.example.authorizationserver.oauth.endpoint; + +import com.example.authorizationserver.annotation.WebIntegrationTest; +import com.example.authorizationserver.oauth.common.GrantType; +import com.example.authorizationserver.oauth.common.TokenType; +import com.example.authorizationserver.oauth.endpoint.token.resource.TokenResponse; +import com.example.authorizationserver.scim.model.ScimUserEntity; +import com.example.authorizationserver.scim.service.ScimService; +import com.example.authorizationserver.token.store.TokenService; +import com.example.authorizationserver.token.store.model.JsonWebToken; +import com.example.authorizationserver.token.store.model.OpaqueToken; +import io.restassured.http.ContentType; +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.context.WebApplicationContext; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Optional; +import java.util.Set; + +import static com.example.authorizationserver.oauth.endpoint.token.TokenEndpoint.ENDPOINT; +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.not; + +@WebIntegrationTest +class TokenExchangeEndpointIntegrationTest { + + public static final int EXPIRY = 600; + public static final String BEARER = "Bearer"; + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private ScimService scimService; + + @Autowired + private TokenService tokenService; + + private ScimUserEntity bwayne_user; + + @BeforeEach + void initMockMvc() { + RestAssuredMockMvc.webAppContextSetup(webApplicationContext); + Optional bwayne = scimService.findUserByUserName("bwayne"); + bwayne.ifPresent(user -> bwayne_user = user); + } + + @Test + void exchangeJwtAccessTokenSuccess() { + + JsonWebToken jwtAccessToken = tokenService.createPersonalizedJwtAccessToken(bwayne_user, "confidential-jwt", "1234", Set.of("openid", "profile"), Duration.ofMinutes(10)); + + TokenResponse tokenExchangeResponse = + given() + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString("token-exchange:demo".getBytes(StandardCharsets.UTF_8))) + .contentType(ContentType.URLENC) + .formParam("grant_type", GrantType.TOKEN_EXCHANGE.getGrant()) + .formParam("resource", "myresource") + .formParam("audience", "myaudience") + .formParam("scope", "openid profile address") + .formParam("requested_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .formParam("subject_token", jwtAccessToken.getValue()) + .formParam("subject_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .when() + .post(ENDPOINT) + .then() + .log() + .ifValidationFails() + .statusCode(200) + .contentType(ContentType.JSON) + .body(not(empty())) + .extract() + .as(TokenResponse.class); + assertThat(tokenExchangeResponse).describedAs("token exchange response should be present").isNotNull(); + assertThat(tokenExchangeResponse.getAccess_token()) + .describedAs("access token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getIssued_token_type()).describedAs("issued token type should be present").isNotBlank(); + assertThat(tokenExchangeResponse.getRefresh_token()) + .describedAs("refresh token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getToken_type()) + .describedAs("token type must be %s", BEARER) + .isEqualTo(BEARER); + assertThat(tokenExchangeResponse.getScope()) + .describedAs("scope must be %s", "openid profile address") + .isEqualTo("openid profile address"); + assertThat(tokenExchangeResponse.getExpires_in()) + .describedAs("expires in must be %s", EXPIRY) + .isEqualTo(EXPIRY); + assertThat(tokenExchangeResponse.getError()).describedAs("error must not be present").isNull(); + } + + @Test + void exchangeAnonymousJwtAccessTokenSuccess() { + + JsonWebToken jwtAccessToken = tokenService.createAnonymousJwtAccessToken("confidential-jwt", Set.of("openid", "profile"), Duration.ofMinutes(10)); + + TokenResponse tokenExchangeResponse = + given() + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString("token-exchange:demo".getBytes(StandardCharsets.UTF_8))) + .contentType(ContentType.URLENC) + .formParam("grant_type", GrantType.TOKEN_EXCHANGE.getGrant()) + .formParam("resource", "myresource") + .formParam("audience", "myaudience") + .formParam("scope", "openid profile address") + .formParam("requested_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .formParam("subject_token", jwtAccessToken.getValue()) + .formParam("subject_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .when() + .post(ENDPOINT) + .then() + .log() + .ifValidationFails() + .statusCode(200) + .contentType(ContentType.JSON) + .body(not(empty())) + .extract() + .as(TokenResponse.class); + assertThat(tokenExchangeResponse).describedAs("token exchange response should be present").isNotNull(); + assertThat(tokenExchangeResponse.getAccess_token()) + .describedAs("access token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getIssued_token_type()).describedAs("issued token type should be present").isNotBlank(); + assertThat(tokenExchangeResponse.getRefresh_token()) + .describedAs("refresh token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getToken_type()) + .describedAs("token type must be %s", BEARER) + .isEqualTo(BEARER); + assertThat(tokenExchangeResponse.getScope()) + .describedAs("scope must be %s", "openid profile address") + .isEqualTo("openid profile address"); + assertThat(tokenExchangeResponse.getExpires_in()) + .describedAs("expires in must be %s", EXPIRY) + .isEqualTo(EXPIRY); + assertThat(tokenExchangeResponse.getError()).describedAs("error must not be present").isNull(); + } + + @Test + void exchangeOpaqueAccessTokenSuccess() { + + OpaqueToken opaqueAccessToken = tokenService.createPersonalizedOpaqueAccessToken(bwayne_user, "confidential-jwt", Set.of("openid", "profile"), Duration.ofMinutes(10)); + + TokenResponse tokenExchangeResponse = + given() + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString("token-exchange:demo".getBytes(StandardCharsets.UTF_8))) + .contentType(ContentType.URLENC) + .formParam("grant_type", GrantType.TOKEN_EXCHANGE.getGrant()) + .formParam("resource", "myresource") + .formParam("audience", "myaudience") + .formParam("scope", "openid profile address") + .formParam("requested_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .formParam("subject_token", opaqueAccessToken.getValue()) + .formParam("subject_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .when() + .post(ENDPOINT) + .then() + .log() + .ifValidationFails() + .statusCode(200) + .contentType(ContentType.JSON) + .body(not(empty())) + .extract() + .as(TokenResponse.class); + assertThat(tokenExchangeResponse).describedAs("token exchange response should be present").isNotNull(); + assertThat(tokenExchangeResponse.getAccess_token()) + .describedAs("access token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getIssued_token_type()).describedAs("issued token type should be present").isNotBlank(); + assertThat(tokenExchangeResponse.getRefresh_token()) + .describedAs("refresh token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getToken_type()) + .describedAs("token type must be %s", BEARER) + .isEqualTo(BEARER); + assertThat(tokenExchangeResponse.getScope()) + .describedAs("scope must be %s", "openid profile address") + .isEqualTo("openid profile address"); + assertThat(tokenExchangeResponse.getExpires_in()) + .describedAs("expires in must be %s", EXPIRY) + .isEqualTo(EXPIRY); + assertThat(tokenExchangeResponse.getError()).describedAs("error must not be present").isNull(); + } + + @Test + void exchangeJwtIDTokenSuccess() { + + JsonWebToken jwtIDToken = tokenService.createIdToken(bwayne_user, "confidential-jwt", "1234", Set.of("openid", "profile"), Duration.ofMinutes(10)); + + TokenResponse tokenExchangeResponse = + given() + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString("token-exchange:demo".getBytes(StandardCharsets.UTF_8))) + .contentType(ContentType.URLENC) + .formParam("grant_type", GrantType.TOKEN_EXCHANGE.getGrant()) + .formParam("resource", "myresource") + .formParam("audience", "myaudience") + .formParam("scope", "openid profile address") + .formParam("requested_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .formParam("subject_token", jwtIDToken.getValue()) + .formParam("subject_token_type", TokenType.ID_TOKEN.getIdentifier()) + .when() + .post(ENDPOINT) + .then() + .log() + .ifValidationFails() + .statusCode(200) + .contentType(ContentType.JSON) + .body(not(empty())) + .extract() + .as(TokenResponse.class); + assertThat(tokenExchangeResponse).describedAs("token exchange response should be present").isNotNull(); + assertThat(tokenExchangeResponse.getAccess_token()) + .describedAs("access token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getIssued_token_type()).describedAs("issued token type should be present").isNotBlank(); + assertThat(tokenExchangeResponse.getRefresh_token()) + .describedAs("refresh token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getToken_type()) + .describedAs("token type must be %s", BEARER) + .isEqualTo(BEARER); + assertThat(tokenExchangeResponse.getScope()) + .describedAs("scope must be %s", "openid profile address") + .isEqualTo("openid profile address"); + assertThat(tokenExchangeResponse.getExpires_in()) + .describedAs("expires in must be %s", EXPIRY) + .isEqualTo(EXPIRY); + assertThat(tokenExchangeResponse.getError()).describedAs("error must not be present").isNull(); + } + + @Test + void exchangeJwtRefreshTokenSuccess() { + + OpaqueToken refreshToken = tokenService.createPersonalizedRefreshToken("confidential-jwt", bwayne_user, Set.of("openid", "profile"), Duration.ofMinutes(10)); + + TokenResponse tokenExchangeResponse = + given() + .header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString("token-exchange:demo".getBytes(StandardCharsets.UTF_8))) + .contentType(ContentType.URLENC) + .formParam("grant_type", GrantType.TOKEN_EXCHANGE.getGrant()) + .formParam("resource", "myresource") + .formParam("audience", "myaudience") + .formParam("scope", "openid profile address") + .formParam("requested_token_type", TokenType.ACCESS_TOKEN.getIdentifier()) + .formParam("subject_token", refreshToken.getValue()) + .formParam("subject_token_type", TokenType.REFRESH_TOKEN.getIdentifier()) + .when() + .post(ENDPOINT) + .then() + .log() + .ifValidationFails() + .statusCode(200) + .contentType(ContentType.JSON) + .body(not(empty())) + .extract() + .as(TokenResponse.class); + assertThat(tokenExchangeResponse).describedAs("token exchange response should be present").isNotNull(); + assertThat(tokenExchangeResponse.getAccess_token()) + .describedAs("access token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getIssued_token_type()).describedAs("issued token type should be present").isNotBlank(); + assertThat(tokenExchangeResponse.getRefresh_token()) + .describedAs("refresh token should be present") + .isNotBlank(); + assertThat(tokenExchangeResponse.getToken_type()) + .describedAs("token type must be %s", BEARER) + .isEqualTo(BEARER); + assertThat(tokenExchangeResponse.getScope()) + .describedAs("scope must be %s", "openid profile address") + .isEqualTo("openid profile address"); + assertThat(tokenExchangeResponse.getExpires_in()) + .describedAs("expires in must be %s", EXPIRY) + .isEqualTo(EXPIRY); + assertThat(tokenExchangeResponse.getError()).describedAs("error must not be present").isNull(); + } +} From 71edd77f77c53b0a11de41644013efe7129c8642 Mon Sep 17 00:00:00 2001 From: Andreas Falk Date: Thu, 23 Dec 2021 18:55:42 +0100 Subject: [PATCH 2/2] upgrade gradle 7.3.2 and spring boot 2.6.2 --- build.gradle | 8 ++++---- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index f272f53..cd2d6ed 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,9 @@ plugins { - id 'org.springframework.boot' version '2.5.4' + id 'org.springframework.boot' version '2.6.2' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' id 'com.adarshr.test-logger' version '2.1.1' - id 'org.owasp.dependencycheck' version '6.1.2' + id 'org.owasp.dependencycheck' version '6.5.1' } group = 'com.example' @@ -26,8 +26,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springdoc:springdoc-openapi-ui:1.5.10' - implementation 'com.nimbusds:nimbus-jose-jwt:9.13' + implementation 'org.springdoc:springdoc-openapi-ui:1.6.2' + implementation 'com.nimbusds:nimbus-jose-jwt:9.15.2' implementation 'org.apache.commons:commons-lang3' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 157702f..a5bd6b3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists \ No newline at end of file