From 4c58f9efdf5400b989e71f1bb343538f5006f97f Mon Sep 17 00:00:00 2001 From: Enrico Risa Date: Thu, 26 Oct 2023 15:05:00 +0200 Subject: [PATCH] feat: Secure Token Service remote implementation --- .../SelfIssuedIdTokenValidator.java | 4 - .../identity-trust-sts/build.gradle.kts | 2 + .../api/sts/model/StsTokenRequest.java | 31 ++- .../StsClientTokenGeneratorServiceImpl.java | 19 +- ...StsClientTokenIssuanceIntegrationTest.java | 68 ++++++ ...tsClientTokenGeneratorServiceImplTest.java | 3 +- .../build.gradle.kts | 1 - .../embedded/EmbeddedSecureTokenService.java | 7 + ...ddedSecureTokenServiceIntegrationTest.java | 34 ++- .../build.gradle.kts | 14 ++ ...StsRemoteClientConfigurationExtension.java | 77 +++++++ .../remote/core/StsRemoteClientExtension.java | 51 +++++ ...rg.eclipse.edc.spi.system.ServiceExtension | 16 ++ ...emoteClientConfigurationExtensionTest.java | 80 +++++++ .../core/StsRemoteClientExtensionTest.java | 46 ++++ .../build.gradle.kts | 13 ++ .../sts/remote/RemoteSecureTokenService.java | 79 +++++++ .../remote/StsRemoteClientConfiguration.java | 82 +++++++ .../remote/RemoteSecureTokenServiceTest.java | 144 ++++++++++++ settings.gradle.kts | 2 + .../sts-api-test-runner/build.gradle.kts | 2 + .../e2e/sts/api/RemoteStsEndToEndTest.java | 215 ++++++++++++++++++ .../api}/StsApiEndToEndTest.java | 161 ++++++++++--- 23 files changed, 1113 insertions(+), 38 deletions(-) create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/build.gradle.kts create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtension.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtension.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtensionTest.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtensionTest.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/build.gradle.kts create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenService.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/StsRemoteClientConfiguration.java create mode 100644 extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenServiceTest.java create mode 100644 system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/RemoteStsEndToEndTest.java rename system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/{stsapi => sts/api}/StsApiEndToEndTest.java (51%) diff --git a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/validation/SelfIssuedIdTokenValidator.java b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/validation/SelfIssuedIdTokenValidator.java index 0effe69971b..4f238c36d4d 100644 --- a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/validation/SelfIssuedIdTokenValidator.java +++ b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/validation/SelfIssuedIdTokenValidator.java @@ -56,7 +56,6 @@ public Result validateToken(TokenRepresentation tokenRepresentation, var iss = claims.getIssuer(); var aud = claims.getAudience(); var jti = claims.getClaim("jti"); - var clientId = claims.getClaim("client_id"); var sub = claims.getSubject(); var exp = claims.getExpirationTime(); var subJwk = claims.getClaim("sub_jwk"); @@ -70,9 +69,6 @@ public Result validateToken(TokenRepresentation tokenRepresentation, if (!aud.contains(audience)) { return failure("The aud claim expected to be %s but was %s".formatted(audience, aud)); } - if (!Objects.equals(clientId, iss)) { - return failure("The client_id must be equal to the issuer ID"); - } if (jti == null) { return failure("The jti claim is mandatory."); } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-sts/build.gradle.kts index 3d4e1bf04ee..acb38c602f4 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/build.gradle.kts +++ b/extensions/common/iam/identity-trust/identity-trust-sts/build.gradle.kts @@ -20,6 +20,8 @@ plugins { dependencies { api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-embedded")) api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-core")) + api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote")) + api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote-core")) api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-api")) api(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-client-configuration")) } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-api/src/main/java/org/eclipse/edc/connector/api/sts/model/StsTokenRequest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-api/src/main/java/org/eclipse/edc/connector/api/sts/model/StsTokenRequest.java index 120d71d5a83..0a2ccecee4d 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-api/src/main/java/org/eclipse/edc/connector/api/sts/model/StsTokenRequest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-api/src/main/java/org/eclipse/edc/connector/api/sts/model/StsTokenRequest.java @@ -14,6 +14,8 @@ package org.eclipse.edc.connector.api.sts.model; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterStyle; import jakarta.ws.rs.FormParam; /** @@ -25,23 +27,41 @@ *
  • clientSecret: Authorization secret for the client/
  • *
  • audience: Audience according to the spec.
  • *
  • bearerAccessScope: Space-delimited scopes to be included in the access_token claim.
  • + *
  • bearerAccessAlias: Alias to be use in the sub of the VP access token (default is audience).
  • *
  • accessToken: VP/VC Access Token to be included as access_token claim.
  • *
  • grantType: Type of grant. Must be client_credentials.
  • * */ public final class StsTokenRequest { - + + @Parameter(name = "grant_type", description = "Type of grant: must be set to client_credentials", required = true, style = ParameterStyle.FORM) @FormParam("grant_type") private String grantType; + + + @Parameter(name = "client_id", description = "Id of the client requesting an SI token", required = true, style = ParameterStyle.FORM) @FormParam("client_id") private String clientId; + + @Parameter(name = "audience", description = "Audience for the SI token", required = true, style = ParameterStyle.FORM) @FormParam("audience") private String audience; + + @Parameter(name = "bearer_access_scope", description = "Scope to be added in the VP access token", style = ParameterStyle.FORM) @FormParam("bearer_access_scope") private String bearerAccessScope; + + + @Parameter(name = "bearer_access_alias", description = "Alias to be use in the sub of the VP access token (default is audience)", style = ParameterStyle.FORM) + @FormParam("bearer_access_alias") + private String bearerAccessAlias; + + @Parameter(name = "access_token", description = "VP access token to be added as a claim in the SI token", style = ParameterStyle.FORM) @FormParam("access_token") private String accessToken; + + @Parameter(name = "client_secret", description = "Secret of the client requesting an SI token", required = true, style = ParameterStyle.FORM) @FormParam("client_secret") private String clientSecret; @@ -69,6 +89,10 @@ public String getBearerAccessScope() { return bearerAccessScope; } + public String getBearerAccessAlias() { + return bearerAccessAlias; + } + public String getAccessToken() { return accessToken; } @@ -105,6 +129,11 @@ public Builder bearerAccessScope(String bearerAccessScope) { return this; } + public Builder bearerAccessAlias(String bearerAccessAlias) { + this.request.bearerAccessAlias = bearerAccessAlias; + return this; + } + public Builder accessToken(String accessToken) { this.request.accessToken = accessToken; return this; diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImpl.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImpl.java index 6f5109f1874..c586b3baa2c 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImpl.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImpl.java @@ -23,7 +23,11 @@ import org.eclipse.edc.spi.iam.TokenRepresentation; import java.time.Clock; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.function.Function; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.CLIENT_ID; @@ -32,6 +36,7 @@ public class StsClientTokenGeneratorServiceImpl implements StsClientTokenGeneratorService { + public static final String ACCESS_TOKEN_CLAIM = "access_token"; private final long tokenExpiration; private final StsTokenGenerationProvider tokenGenerationProvider; private final Clock clock; @@ -47,12 +52,16 @@ public StsClientTokenGeneratorServiceImpl(StsTokenGenerationProvider tokenGenera public ServiceResult tokenFor(StsClient client, StsClientTokenAdditionalParams additionalParams) { var embeddedTokenGenerator = new EmbeddedSecureTokenService(tokenGenerationProvider.tokenGeneratorFor(client), clock, tokenExpiration); - var claims = Map.of( + var initialClaims = Map.of( ISSUER, client.getId(), SUBJECT, client.getId(), AUDIENCE, additionalParams.getAudience(), CLIENT_ID, client.getClientId()); + var claims = Optional.ofNullable(additionalParams.getAccessToken()) + .map(enrichClaims(initialClaims)) + .orElse(initialClaims); + var tokenResult = embeddedTokenGenerator.createToken(claims, additionalParams.getBearerAccessScope()) .map(this::enrichWithExpiration); @@ -70,4 +79,12 @@ private TokenRepresentation enrichWithExpiration(TokenRepresentation tokenRepres .build(); } + private Function> enrichClaims(Map claims) { + return (token) -> { + var newClaims = new HashMap<>(claims); + newClaims.put(ACCESS_TOKEN_CLAIM, token); + return Collections.unmodifiableMap(newClaims); + }; + } + } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/StsClientTokenIssuanceIntegrationTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/StsClientTokenIssuanceIntegrationTest.java index 2a398d536ac..0e2ee69961c 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/StsClientTokenIssuanceIntegrationTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/StsClientTokenIssuanceIntegrationTest.java @@ -84,6 +84,72 @@ void authenticateAndGenerateToken() throws Exception { vault.storeSecret(privateKeyAlis, loadResourceFile("ec-privatekey.pem")); + var createResult = clientService.create(client); + assertThat(createResult.succeeded()).isTrue(); + + var tokenResult = tokenGeneratorService.tokenFor(client, additional); + var jwt = SignedJWT.parse(tokenResult.getContent().getToken()); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, id) + .containsEntry(SUBJECT, id) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry("client_id", clientId) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + + } + + @Test + void authenticateAndGenerateToken_withBearerAccessScope() throws Exception { + var id = "id"; + var clientId = "client_id"; + var secretAlias = "client_id"; + var privateKeyAlis = "client_id"; + var audience = "aud"; + var scope = "scope:test"; + var client = createClientBuilder(id) + .clientId(clientId) + .privateKeyAlias(privateKeyAlis) + .secretAlias(secretAlias) + .build(); + + var additional = StsClientTokenAdditionalParams.Builder.newInstance().audience(audience).bearerAccessScope(scope).build(); + + vault.storeSecret(privateKeyAlis, loadResourceFile("ec-privatekey.pem")); + + var createResult = clientService.create(client); + assertThat(createResult.succeeded()).isTrue(); + + var tokenResult = tokenGeneratorService.tokenFor(client, additional); + var jwt = SignedJWT.parse(tokenResult.getContent().getToken()); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, id) + .containsEntry(SUBJECT, id) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry("client_id", clientId) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT, "access_token"); + + } + + + @Test + void authenticateAndGenerateToken_withAccessToken() throws Exception { + var id = "id"; + var clientId = "client_id"; + var secretAlias = "client_id"; + var privateKeyAlis = "client_id"; + var audience = "aud"; + var accessToken = "tokenTest"; + var client = createClientBuilder(id) + .clientId(clientId) + .privateKeyAlias(privateKeyAlis) + .secretAlias(secretAlias) + .build(); + + var additional = StsClientTokenAdditionalParams.Builder.newInstance().audience(audience).accessToken(accessToken).build(); + + vault.storeSecret(privateKeyAlis, loadResourceFile("ec-privatekey.pem")); var createResult = clientService.create(client); assertThat(createResult.succeeded()).isTrue(); @@ -96,10 +162,12 @@ void authenticateAndGenerateToken() throws Exception { .containsEntry(SUBJECT, id) .containsEntry(AUDIENCE, List.of(audience)) .containsEntry("client_id", clientId) + .containsEntry("access_token", accessToken) .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); } + /** * Load content from a resource file. */ diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImplTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImplTest.java index cab0afcaa65..575437b6e8c 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImplTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/core/defaults/service/StsClientTokenGeneratorServiceImplTest.java @@ -39,8 +39,7 @@ public class StsClientTokenGeneratorServiceImplTest { private final StsTokenGenerationProvider tokenGenerationProvider = mock(); private final TokenGenerationService tokenGenerator = mock(); private StsClientTokenGeneratorServiceImpl clientTokenService; - - + @BeforeEach void setup() { clientTokenService = new StsClientTokenGeneratorServiceImpl(tokenGenerationProvider, Clock.systemUTC(), TOKEN_EXPIRATION); diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/build.gradle.kts index 62a6830d8c6..2960d92a25b 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/build.gradle.kts +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/build.gradle.kts @@ -8,7 +8,6 @@ dependencies { api(project(":spi:common:jwt-spi")) implementation(project(":core:common:util")) - implementation(project(":extensions:common:iam:identity-trust:identity-trust-service")) testImplementation(testFixtures(project(":spi:common:identity-trust-spi"))) testImplementation(project(":core:common:junit")) testImplementation(project(":core:common:jwt-core")) diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java index 98cb8a8b510..6886403f3cd 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java @@ -45,6 +45,7 @@ public class EmbeddedSecureTokenService implements SecureTokenService { public static final String SCOPE_CLAIM = "scope"; public static final String ACCESS_TOKEN_CLAIM = "access_token"; + public static final String BEARER_ACCESS_ALIAS_CLAIM = "bearer_access_alias"; private static final List ACCESS_TOKEN_INHERITED_CLAIMS = List.of(ISSUER); private final TokenGenerationService tokenGenerationService; private final Clock clock; @@ -77,6 +78,7 @@ private Result createAccessToken(Map claims accessTokenClaims.put(SCOPE_CLAIM, bearerAccessScope); return addClaim(claims, ISSUER, withClaim(AUDIENCE, accessTokenClaims::put)) .compose(v -> addClaim(claims, AUDIENCE, withClaim(SUBJECT, accessTokenClaims::put))) + .compose(v -> addOptionalClaim(claims, BEARER_ACCESS_ALIAS_CLAIM, withClaim(SUBJECT, accessTokenClaims::put))) .compose(v -> tokenGenerationService.generate(new SelfIssuedTokenDecorator(accessTokenClaims, clock, validity))); } @@ -91,6 +93,11 @@ private Result addClaim(Map claims, String claim, Consumer } } + private Result addOptionalClaim(Map claims, String claim, Consumer consumer) { + addClaim(claims, claim, consumer); + return Result.success(); + } + private Consumer withClaim(String key, BiConsumer consumer) { return (value) -> consumer.accept(key, value); } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java index 89636097a2b..a0558e32066 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java @@ -41,6 +41,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.InstanceOfAssertFactories.STRING; import static org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService.ACCESS_TOKEN_CLAIM; +import static org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService.BEARER_ACCESS_ALIAS_CLAIM; import static org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService.SCOPE_CLAIM; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; @@ -117,6 +118,37 @@ void createToken_withBearerAccessScope() { }); } + @Test + void createToken_withBearerAccessAlias() { + var scopes = "email:read"; + var issuer = "testIssuer"; + var audience = "audience"; + var bearerAccessAlias = "alias"; + var claims = Map.of(ISSUER, issuer, AUDIENCE, audience, BEARER_ACCESS_ALIAS_CLAIM, bearerAccessAlias); + var tokenResult = secureTokenService.createToken(claims, scopes); + + assertThat(tokenResult).isSucceeded() + .satisfies(tokenRepresentation -> { + var jwt = SignedJWT.parse(tokenRepresentation.getToken()); + assertThat(jwt.verify(createVerifier(jwt.getHeader(), keyPair.getPublic()))).isTrue(); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, issuer) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT) + .extractingByKey(ACCESS_TOKEN_CLAIM, as(STRING)) + .satisfies(accessToken -> { + var accessTokenJwt = SignedJWT.parse(accessToken); + assertThat(accessTokenJwt.verify(createVerifier(accessTokenJwt.getHeader(), keyPair.getPublic()))).isTrue(); + assertThat(accessTokenJwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, issuer) + .containsEntry(SUBJECT, bearerAccessAlias) + .containsEntry(AUDIENCE, List.of(issuer)) + .containsEntry(SCOPE_CLAIM, scopes) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + }); + }); + } + @ParameterizedTest @ArgumentsSource(ClaimsArguments.class) @@ -133,7 +165,7 @@ private JWSVerifier createVerifier(JWSHeader header, Key publicKey) throws JOSEE private static class ClaimsArguments implements ArgumentsProvider { @Override - public Stream provideArguments(ExtensionContext extensionContext) throws Exception { + public Stream provideArguments(ExtensionContext extensionContext) { return Stream.of(Map.of(ISSUER, "iss"), Map.of(AUDIENCE, "aud")).map(Arguments::of); } } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/build.gradle.kts new file mode 100644 index 00000000000..e66fdbf4bbe --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` + `maven-publish` +} + +dependencies { + api(project(":spi:common:identity-trust-spi")) + api(project(":spi:common:oauth2-spi")) + api(project(":spi:common:jwt-spi")) + implementation(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote")) + + testImplementation(project(":core:common:junit")) +} + diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtension.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtension.java new file mode 100644 index 00000000000..e45dd6324e0 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtension.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote.core; + +import org.eclipse.edc.iam.identitytrust.sts.remote.StsRemoteClientConfiguration; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; + +import java.util.Objects; + +/** + * Configuration Extension for the STS OAuth2 client + */ +@Extension(StsRemoteClientConfigurationExtension.NAME) +public class StsRemoteClientConfigurationExtension implements ServiceExtension { + + @Setting(value = "STS OAuth2 endpoint for requesting a token") + public static final String TOKEN_URL = "edc.iam.sts.oauth.token.url"; + + @Setting(value = "STS OAuth2 client id") + public static final String CLIENT_ID = "edc.iam.sts.oauth.client.id"; + + @Setting(value = "Vault alias of STS OAuth2 client secret") + public static final String CLIENT_SECRET_ALIAS = "edc.iam.sts.oauth.client.secret.alias"; + + protected static final String NAME = "Sts remote client configuration extension"; + + + @Inject + private Vault vault; + + @Override + public String name() { + return NAME; + } + + @Provider + public StsRemoteClientConfiguration clientConfiguration(ServiceExtensionContext context) { + + var tokenUrl = removeTrailingSlash(context.getConfig().getString(TOKEN_URL)); + var clientId = context.getConfig().getString(CLIENT_ID); + var clientSecretAlias = context.getConfig().getString(CLIENT_SECRET_ALIAS); + var clientSecret = vault.resolveSecret(clientSecretAlias); + Objects.requireNonNull(clientSecret, "Client secret could not be retrieved"); + + return StsRemoteClientConfiguration.Builder.newInstance() + .tokenUrl(tokenUrl) + .clientId(clientId) + .clientSecret(clientSecret) + .build(); + } + + private String removeTrailingSlash(String path) { + var fixedPath = path; + if (fixedPath.endsWith("/")) { + fixedPath = fixedPath.substring(0, fixedPath.length() - 1); + } + return fixedPath; + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtension.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtension.java new file mode 100644 index 00000000000..5c71d092d2d --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtension.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote.core; + +import org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService; +import org.eclipse.edc.iam.identitytrust.sts.remote.StsRemoteClientConfiguration; +import org.eclipse.edc.iam.oauth2.spi.client.Oauth2Client; +import org.eclipse.edc.identitytrust.SecureTokenService; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.spi.system.ServiceExtension; + +/** + * Configuration Extension for the STS OAuth2 client + */ +@Extension(StsRemoteClientExtension.NAME) +public class StsRemoteClientExtension implements ServiceExtension { + + + protected static final String NAME = "Sts remote client configuration extension"; + + + @Inject + private StsRemoteClientConfiguration clientConfiguration; + + @Inject + private Oauth2Client oauth2Client; + + @Override + public String name() { + return NAME; + } + + @Provider + public SecureTokenService secureTokenService() { + return new RemoteSecureTokenService(oauth2Client, clientConfiguration); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 00000000000..57ba67f05e5 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,16 @@ +# +# Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation +# +# + +org.eclipse.edc.iam.identitytrust.sts.remote.core.StsRemoteClientConfigurationExtension +org.eclipse.edc.iam.identitytrust.sts.remote.core.StsRemoteClientExtension \ No newline at end of file diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtensionTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtensionTest.java new file mode 100644 index 00000000000..557c07642c4 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientConfigurationExtensionTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote.core; + +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.system.configuration.ConfigFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.iam.identitytrust.sts.remote.core.StsRemoteClientConfigurationExtension.CLIENT_ID; +import static org.eclipse.edc.iam.identitytrust.sts.remote.core.StsRemoteClientConfigurationExtension.CLIENT_SECRET_ALIAS; +import static org.eclipse.edc.iam.identitytrust.sts.remote.core.StsRemoteClientConfigurationExtension.TOKEN_URL; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(DependencyInjectionExtension.class) +public class StsRemoteClientConfigurationExtensionTest { + + @BeforeEach + void setup(ServiceExtensionContext context) { + context.registerService(Vault.class, mock()); + } + + @Test + void initialize(StsRemoteClientConfigurationExtension extension, ServiceExtensionContext context, Vault vault) { + + var tokenUrl = "http://tokenUrl"; + var clientId = "clientId"; + var secretAlias = "secretAlias"; + + when(vault.resolveSecret(secretAlias)).thenReturn(secretAlias); + + var configMap = Map.of(TOKEN_URL, tokenUrl, CLIENT_ID, clientId, CLIENT_SECRET_ALIAS, secretAlias); + var config = ConfigFactory.fromMap(configMap); + + when(context.getConfig()).thenReturn(config); + + extension.initialize(context); + assertThat(extension.clientConfiguration(context)).isNotNull() + .satisfies(configuration -> { + assertThat(configuration.getTokenUrl()).isEqualTo(tokenUrl); + assertThat(configuration.getClientId()).isEqualTo(clientId); + assertThat(configuration.getClientSecret()).isEqualTo(secretAlias); + }); + } + + @Test + void initialize_fail_withVaultSecretResolutionError(StsRemoteClientConfigurationExtension extension, ServiceExtensionContext context, Vault vault) { + + var tokenUrl = "http://tokenUrl"; + var clientId = "clientId"; + var secretAlias = "secretAlias"; + + var configMap = Map.of(TOKEN_URL, tokenUrl, CLIENT_ID, clientId, CLIENT_SECRET_ALIAS, secretAlias); + var config = ConfigFactory.fromMap(configMap); + + when(context.getConfig()).thenReturn(config); + + assertThatThrownBy(() -> extension.clientConfiguration(context)).isInstanceOf(NullPointerException.class); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtensionTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtensionTest.java new file mode 100644 index 00000000000..99def648bdf --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/core/StsRemoteClientExtensionTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote.core; + +import org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService; +import org.eclipse.edc.iam.identitytrust.sts.remote.StsRemoteClientConfiguration; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(DependencyInjectionExtension.class) +public class StsRemoteClientExtensionTest { + + private final StsRemoteClientConfiguration configuration = StsRemoteClientConfiguration.Builder.newInstance() + .clientId("id") + .clientSecret("secret") + .tokenUrl("url") + .build(); + + @BeforeEach + void setup(ServiceExtensionContext context) { + context.registerService(StsRemoteClientConfiguration.class, configuration); + } + + @Test + void initialize(StsRemoteClientExtension extension, ServiceExtensionContext context) { + extension.initialize(context); + assertThat(extension.secureTokenService()).isInstanceOf(RemoteSecureTokenService.class); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/build.gradle.kts new file mode 100644 index 00000000000..6341eb1bc81 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` + `maven-publish` +} + +dependencies { + api(project(":spi:common:identity-trust-spi")) + api(project(":spi:common:oauth2-spi")) + api(project(":spi:common:jwt-spi")) + + testImplementation(project(":core:common:junit")) +} + diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenService.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenService.java new file mode 100644 index 00000000000..848ba46d166 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenService.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote; + +import org.eclipse.edc.iam.oauth2.spi.client.Oauth2Client; +import org.eclipse.edc.iam.oauth2.spi.client.Oauth2CredentialsRequest; +import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; +import org.eclipse.edc.identitytrust.SecureTokenService; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.stream.Collectors; + +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; + +public class RemoteSecureTokenService implements SecureTokenService { + + public static final String GRANT_TYPE = "client_credentials"; + public static final String ACCESS_TOKEN_PARAM = "access_token"; + public static final String AUDIENCE_PARAM = "audience"; + public static final String BEARER_ACCESS_ALIAS_PARAM = "bearer_access_alias"; + public static final String BEARER_ACCESS_SCOPE_PARAM = "bearer_access_scope"; + private static final Map CLAIM_MAPPING = Map.of( + AUDIENCE, AUDIENCE_PARAM, + BEARER_ACCESS_ALIAS_PARAM, BEARER_ACCESS_ALIAS_PARAM, + ACCESS_TOKEN_PARAM, ACCESS_TOKEN_PARAM); + private final Oauth2Client oauth2Client; + private final StsRemoteClientConfiguration configuration; + + public RemoteSecureTokenService(Oauth2Client oauth2Client, StsRemoteClientConfiguration configuration) { + this.oauth2Client = oauth2Client; + this.configuration = configuration; + } + + @Override + public Result createToken(Map claims, @Nullable String bearerAccessScope) { + return oauth2Client.requestToken(createRequest(claims, bearerAccessScope)); + } + + @NotNull + private Oauth2CredentialsRequest createRequest(Map claims, @Nullable String bearerAccessScope) { + var builder = SharedSecretOauth2CredentialsRequest.Builder.newInstance() + .url(configuration.getTokenUrl()) + .clientId(configuration.getClientId()) + .clientSecret(configuration.getClientSecret()) + .grantType(GRANT_TYPE); + + if (configuration.getScope() != null) { + builder.scope(configuration.getScope()); + } + + var additionalParams = claims.entrySet().stream() + .filter(entry -> CLAIM_MAPPING.containsKey(entry.getKey())) + .map(entry -> Map.entry(CLAIM_MAPPING.get(entry.getKey()), entry.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + if (bearerAccessScope != null) { + additionalParams.put(BEARER_ACCESS_SCOPE_PARAM, bearerAccessScope); + } + + builder.params(additionalParams); + return builder.build(); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/StsRemoteClientConfiguration.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/StsRemoteClientConfiguration.java new file mode 100644 index 00000000000..bc78b484c2b --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/main/java/org/eclipse/edc/iam/identitytrust/sts/remote/StsRemoteClientConfiguration.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote; + +import java.util.Objects; + +/** + * Configuration of the OAuth2 client + */ +public class StsRemoteClientConfiguration { + private String tokenUrl; + private String clientId; + + private String clientSecret; + private String scope; + + public String getScope() { + return scope; + } + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public static class Builder { + private final StsRemoteClientConfiguration configuration = new StsRemoteClientConfiguration(); + + private Builder() { + } + + public static Builder newInstance() { + return new Builder(); + } + + public Builder tokenUrl(String url) { + configuration.tokenUrl = url; + return this; + } + + public Builder clientId(String clientId) { + configuration.clientId = clientId; + return this; + } + + public Builder scope(String scope) { + configuration.scope = scope; + return this; + } + + public Builder clientSecret(String clientSecret) { + configuration.clientSecret = clientSecret; + return this; + } + + public StsRemoteClientConfiguration build() { + Objects.requireNonNull(configuration.clientId, "Client id"); + Objects.requireNonNull(configuration.clientSecret, "Client secret"); + Objects.requireNonNull(configuration.tokenUrl, "Token Url"); + return configuration; + } + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenServiceTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenServiceTest.java new file mode 100644 index 00000000000..bb8b773704b --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-remote/src/test/java/org/eclipse/edc/iam/identitytrust/sts/remote/RemoteSecureTokenServiceTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.remote; + +import org.eclipse.edc.iam.oauth2.spi.client.Oauth2Client; +import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService.ACCESS_TOKEN_PARAM; +import static org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService.AUDIENCE_PARAM; +import static org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService.BEARER_ACCESS_ALIAS_PARAM; +import static org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService.BEARER_ACCESS_SCOPE_PARAM; +import static org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService.GRANT_TYPE; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RemoteSecureTokenServiceTest { + + private final StsRemoteClientConfiguration configuration = StsRemoteClientConfiguration.Builder.newInstance() + .clientId("id") + .clientSecret("secret") + .tokenUrl("url") + .build(); + private final Oauth2Client oauth2Client = mock(); + private RemoteSecureTokenService secureTokenService; + + @BeforeEach + void setup() { + secureTokenService = new RemoteSecureTokenService(oauth2Client, configuration); + } + + @Test + void createToken() { + var audience = "aud"; + when(oauth2Client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().build())); + assertThat(secureTokenService.createToken(Map.of(AUDIENCE, audience), null)).isSucceeded(); + + var captor = ArgumentCaptor.forClass(SharedSecretOauth2CredentialsRequest.class); + verify(oauth2Client).requestToken(captor.capture()); + + assertThat(captor.getValue()).satisfies(request -> { + assertThat(request.getUrl()).isEqualTo(configuration.getTokenUrl()); + assertThat(request.getClientId()).isEqualTo(configuration.getClientId()); + assertThat(request.getGrantType()).isEqualTo(GRANT_TYPE); + assertThat(request.getClientSecret()).isEqualTo(configuration.getClientSecret()); + assertThat(request.getParams()) + .containsEntry(AUDIENCE_PARAM, audience); + }); + } + + @Test + void createToken_withAccessScope() { + var audience = "aud"; + var bearerAccessScope = "scope"; + when(oauth2Client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().build())); + assertThat(secureTokenService.createToken(Map.of(AUDIENCE, audience), bearerAccessScope)).isSucceeded(); + + var captor = ArgumentCaptor.forClass(SharedSecretOauth2CredentialsRequest.class); + verify(oauth2Client).requestToken(captor.capture()); + + assertThat(captor.getValue()).satisfies(request -> { + assertThat(request.getUrl()).isEqualTo(configuration.getTokenUrl()); + assertThat(request.getClientId()).isEqualTo(configuration.getClientId()); + assertThat(request.getGrantType()).isEqualTo(GRANT_TYPE); + assertThat(request.getClientSecret()).isEqualTo(configuration.getClientSecret()); + assertThat(request.getParams()) + .containsEntry(AUDIENCE_PARAM, audience) + .containsEntry(BEARER_ACCESS_SCOPE_PARAM, bearerAccessScope); + }); + } + + @Test + void createToken_withAccessToken() { + var audience = "aud"; + var accessToken = "accessToken"; + when(oauth2Client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().build())); + assertThat(secureTokenService.createToken(Map.of(AUDIENCE, audience, ACCESS_TOKEN_PARAM, accessToken), null)).isSucceeded(); + + var captor = ArgumentCaptor.forClass(SharedSecretOauth2CredentialsRequest.class); + verify(oauth2Client).requestToken(captor.capture()); + + assertThat(captor.getValue()).satisfies(request -> { + assertThat(request.getUrl()).isEqualTo(configuration.getTokenUrl()); + assertThat(request.getClientId()).isEqualTo(configuration.getClientId()); + assertThat(request.getGrantType()).isEqualTo(GRANT_TYPE); + assertThat(request.getClientSecret()).isEqualTo(configuration.getClientSecret()); + assertThat(request.getParams()) + .containsEntry(AUDIENCE_PARAM, audience) + .containsEntry(ACCESS_TOKEN_PARAM, accessToken); + }); + } + + @Test + void createToken_withBearerAccessTokenAlias() { + var audience = "aud"; + var bearerAccessScope = "scope"; + var bearerAccessAlias = "alias"; + + when(oauth2Client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().build())); + + var claims = Map.of( + AUDIENCE, audience, + BEARER_ACCESS_ALIAS_PARAM, bearerAccessAlias); + + assertThat(secureTokenService.createToken(claims, bearerAccessScope)).isSucceeded(); + + var captor = ArgumentCaptor.forClass(SharedSecretOauth2CredentialsRequest.class); + verify(oauth2Client).requestToken(captor.capture()); + + assertThat(captor.getValue()).satisfies(request -> { + assertThat(request.getUrl()).isEqualTo(configuration.getTokenUrl()); + assertThat(request.getClientId()).isEqualTo(configuration.getClientId()); + assertThat(request.getGrantType()).isEqualTo(GRANT_TYPE); + assertThat(request.getClientSecret()).isEqualTo(configuration.getClientSecret()); + assertThat(request.getParams()) + .containsEntry(AUDIENCE_PARAM, audience) + .containsEntry(BEARER_ACCESS_ALIAS_PARAM, bearerAccessAlias) + .containsEntry(BEARER_ACCESS_SCOPE_PARAM, bearerAccessScope); + }); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e743e0419bd..bf058564e43 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -119,6 +119,8 @@ include(":extensions:common:iam:identity-trust:identity-trust-core") include(":extensions:common:iam:identity-trust:identity-trust-sts") include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-embedded") include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-core") +include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote") +include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote-core") include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-api") include(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-client-configuration") diff --git a/system-tests/sts-api/sts-api-test-runner/build.gradle.kts b/system-tests/sts-api/sts-api-test-runner/build.gradle.kts index c1f9372e90b..3ad75262931 100644 --- a/system-tests/sts-api/sts-api-test-runner/build.gradle.kts +++ b/system-tests/sts-api/sts-api-test-runner/build.gradle.kts @@ -19,6 +19,8 @@ plugins { dependencies { testImplementation(project(":core:common:junit")) testImplementation(project(":spi:common:identity-trust-sts-spi")) + testImplementation(project(":extensions:common:iam:identity-trust:identity-trust-sts:identity-trust-sts-remote")) + testImplementation(project(":extensions:common:iam:oauth2:oauth2-client")) testImplementation(libs.restAssured) testImplementation(libs.assertj) diff --git a/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/RemoteStsEndToEndTest.java b/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/RemoteStsEndToEndTest.java new file mode 100644 index 00000000000..8c623f0bac3 --- /dev/null +++ b/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/RemoteStsEndToEndTest.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2023 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.test.e2e.sts.api; + +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.iam.identitytrust.sts.model.StsClient; +import org.eclipse.edc.iam.identitytrust.sts.remote.RemoteSecureTokenService; +import org.eclipse.edc.iam.identitytrust.sts.remote.StsRemoteClientConfiguration; +import org.eclipse.edc.iam.identitytrust.sts.store.StsClientStore; +import org.eclipse.edc.iam.oauth2.client.Oauth2ClientImpl; +import org.eclipse.edc.junit.annotations.EndToEndTest; +import org.eclipse.edc.junit.extensions.EdcRuntimeExtension; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Failure; +import org.eclipse.edc.spi.security.Vault; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.text.ParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.iam.identitytrust.sts.store.fixtures.TestFunctions.createClient; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.junit.testfixtures.TestUtils.getFreePort; +import static org.eclipse.edc.junit.testfixtures.TestUtils.testHttpClient; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.CLIENT_ID; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.EXPIRATION_TIME; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUED_AT; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUER; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.JWT_ID; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.SUBJECT; + +@EndToEndTest +public class RemoteStsEndToEndTest { + + public static final int PORT = getFreePort(); + public static final String STS_TOKEN_PATH = "http://localhost:" + PORT + "/sts/token"; + + @RegisterExtension + static EdcRuntimeExtension sts = new EdcRuntimeExtension( + ":system-tests:sts-api:sts-api-test-runtime", + "sts", + new HashMap<>() { + { + put("web.http.path", "/"); + put("web.http.port", String.valueOf(getFreePort())); + put("web.http.sts.path", "/sts"); + put("web.http.sts.port", String.valueOf(PORT)); + } + } + ); + private final StsRemoteClientConfiguration config = StsRemoteClientConfiguration.Builder.newInstance() + .clientId("client_id") + .clientSecret("clientSecret") + .tokenUrl(STS_TOKEN_PATH) + .build(); + private RemoteSecureTokenService remoteSecureTokenService; + + @BeforeEach + void setup() { + var oauth2Client = new Oauth2ClientImpl(testHttpClient(), new TypeManager()); + remoteSecureTokenService = new RemoteSecureTokenService(oauth2Client, config); + } + + @Test + void requestToken() { + var audience = "audience"; + var params = Map.of("aud", audience); + + var client = initClient(config.getClientId(), config.getClientSecret()); + + assertThat(remoteSecureTokenService.createToken(params, null)) + .isSucceeded() + .extracting(TokenRepresentation::getToken) + .extracting(this::parseClaims) + .satisfies(claims -> { + assertThat(claims) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, client.getId()) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry(CLIENT_ID, client.getClientId()) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + }); + + } + + + @Test + void requestToken_withBearerScope() { + var audience = "audience"; + var bearerAccessScope = "org.test.Member:read org.test.GoldMember:read"; + var params = Map.of("aud", audience); + + var client = initClient(config.getClientId(), config.getClientSecret()); + + assertThat(remoteSecureTokenService.createToken(params, bearerAccessScope)) + .isSucceeded() + .extracting(TokenRepresentation::getToken) + .extracting(this::parseClaims) + .satisfies(claims -> { + assertThat(claims) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, client.getId()) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry(CLIENT_ID, client.getClientId()) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT) + .hasEntrySatisfying("access_token", (accessToken) -> { + assertThat(parseClaims((String) accessToken)) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, audience) + .containsEntry(AUDIENCE, List.of(client.getClientId())) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + + }); + }); + + } + + @Test + void requestToken_withAttachedAccessToken() { + var audience = "audience"; + var accessToken = "test_token"; + var params = Map.of( + "aud", audience, + "access_token", accessToken); + + + var client = initClient(config.getClientId(), config.getClientSecret()); + + + assertThat(remoteSecureTokenService.createToken(params, null)) + .isSucceeded() + .extracting(TokenRepresentation::getToken) + .extracting(this::parseClaims).satisfies(claims -> { + assertThat(claims) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, client.getId()) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry(CLIENT_ID, client.getClientId()) + .containsEntry("access_token", accessToken) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + }); + } + + @Test + void requestToken_shouldReturnError_whenClientNotFound() { + var audience = "audience"; + var params = Map.of("aud", audience); + + assertThat(remoteSecureTokenService.createToken(params, null)).isFailed() + .extracting(Failure::getFailureDetail) + .satisfies(failure -> assertThat(failure).contains("Invalid client")); + + } + + private StsClient initClient(String clientId, String clientSecret) { + var store = getClientStore(); + var vault = getVault(); + var clientSecretAlias = "client_secret_alias"; + var client = createClient(clientId, clientSecretAlias); + + + vault.storeSecret(clientSecretAlias, clientSecret); + vault.storeSecret(client.getPrivateKeyAlias(), loadResourceFile("ec-privatekey.pem")); + store.create(client); + + return client; + } + + private StsClientStore getClientStore() { + return sts.getContext().getService(StsClientStore.class); + } + + private Vault getVault() { + return sts.getContext().getService(Vault.class); + } + + /** + * Load content from a resource file. + */ + private String loadResourceFile(String file) { + try (var resourceAsStream = RemoteStsEndToEndTest.class.getClassLoader().getResourceAsStream(file)) { + return new String(Objects.requireNonNull(resourceAsStream).readAllBytes()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Map parseClaims(String token) { + try { + return SignedJWT.parse(token).getJWTClaimsSet().getClaims(); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } +} diff --git a/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/stsapi/StsApiEndToEndTest.java b/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/StsApiEndToEndTest.java similarity index 51% rename from system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/stsapi/StsApiEndToEndTest.java rename to system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/StsApiEndToEndTest.java index 8f3d53b404f..27582d433d6 100644 --- a/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/stsapi/StsApiEndToEndTest.java +++ b/system-tests/sts-api/sts-api-test-runner/src/test/java/org/eclipse/edc/test/e2e/sts/api/StsApiEndToEndTest.java @@ -12,11 +12,14 @@ * */ -package org.eclipse.edc.test.e2e.stsapi; +package org.eclipse.edc.test.e2e.sts.api; import com.nimbusds.jwt.SignedJWT; +import io.restassured.response.ValidatableResponse; import io.restassured.specification.RequestSpecification; +import org.eclipse.edc.iam.identitytrust.sts.model.StsClient; import org.eclipse.edc.iam.identitytrust.sts.store.StsClientStore; +import org.eclipse.edc.junit.annotations.EndToEndTest; import org.eclipse.edc.junit.extensions.EdcRuntimeExtension; import org.eclipse.edc.spi.security.Vault; import org.junit.jupiter.api.Test; @@ -26,6 +29,7 @@ import java.text.ParseException; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import static io.restassured.RestAssured.given; @@ -43,6 +47,7 @@ import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; +@EndToEndTest public class StsApiEndToEndTest { public static final int PORT = getFreePort(); @@ -64,28 +69,55 @@ public class StsApiEndToEndTest { ); @Test - void requestToken() throws IOException, ParseException { - var store = getClientStore(); - var vault = getVault(); - var clientId = "client_id"; + void requestToken() throws ParseException { + var audience = "audience"; + var clientSecret = "client_secret"; + var expiresIn = 300; + + var client = initClient(clientSecret); + + var params = Map.of( + "client_id", client.getClientId(), + "audience", audience, + "client_secret", clientSecret); + + var token = tokenRequest(params) + .statusCode(200) + .contentType(JSON) + .body("access_token", notNullValue()) + .body("expires_in", is(expiresIn)) + .extract() + .body() + .jsonPath().getString("access_token"); + + var jwt = SignedJWT.parse(token); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, client.getId()) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry(CLIENT_ID, client.getClientId()) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + } + + + @Test + void requestToken_withBearerScope() throws ParseException { var clientSecret = "client_secret"; - var clientSecretAlias = "client_secret_alias"; var audience = "audience"; + var bearerAccessScope = "org.test.Member:read org.test.GoldMember:read"; var expiresIn = 300; - var client = createClient(clientId, clientSecretAlias); - vault.storeSecret(clientSecretAlias, clientSecret); - vault.storeSecret(client.getPrivateKeyAlias(), loadResourceFile("ec-privatekey.pem")); - store.create(client); + var client = initClient(clientSecret); - var token = baseRequest() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", GRANT_TYPE) - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("audience", audience) - .post("/token") - .then() + + var params = Map.of( + "client_id", client.getClientId(), + "audience", audience, + "bearer_access_scope", bearerAccessScope, + "client_secret", clientSecret); + + var token = tokenRequest(params) .statusCode(200) .contentType(JSON) .body("access_token", notNullValue()) @@ -102,6 +134,56 @@ void requestToken() throws IOException, ParseException { .containsEntry(SUBJECT, client.getId()) .containsEntry(AUDIENCE, List.of(audience)) .containsEntry(CLIENT_ID, client.getClientId()) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT) + .hasEntrySatisfying("access_token", (accessToken) -> { + try { + var accessTokenJwt = SignedJWT.parse(((String) accessToken)); + + assertThat(accessTokenJwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, audience) + .containsEntry(AUDIENCE, List.of(client.getClientId())) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + + } catch (ParseException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + void requestToken_withAttachedAccessScope() throws IOException, ParseException { + var clientSecret = "client_secret"; + var audience = "audience"; + var accessToken = "test_token"; + var expiresIn = 300; + var client = initClient(clientSecret); + + + var params = Map.of( + "client_id", client.getClientId(), + "audience", audience, + "access_token", accessToken, + "client_secret", clientSecret); + + var token = tokenRequest(params) + .statusCode(200) + .contentType(JSON) + .body("access_token", notNullValue()) + .body("expires_in", is(expiresIn)) + .extract() + .body() + .jsonPath().getString("access_token"); + + + var jwt = SignedJWT.parse(token); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, client.getId()) + .containsEntry(SUBJECT, client.getId()) + .containsEntry(AUDIENCE, List.of(audience)) + .containsEntry(CLIENT_ID, client.getClientId()) + .containsEntry("access_token", accessToken) .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); } @@ -112,19 +194,25 @@ void requestToken_shouldReturnError_whenClientNotFound() { var clientSecret = "client_secret"; var audience = "audience"; - baseRequest() - .contentType("application/x-www-form-urlencoded") - .formParam("grant_type", GRANT_TYPE) - .formParam("client_id", clientId) - .formParam("client_secret", clientSecret) - .formParam("audience", audience) - .post("/token") - .then() - .log().all(true) + var params = Map.of( + "client_id", clientId, + "audience", audience, + "client_secret", clientSecret); + + tokenRequest(params) .statusCode(401) .contentType(JSON); } + protected ValidatableResponse tokenRequest(Map params) { + + var req = baseRequest() + .contentType("application/x-www-form-urlencoded") + .formParam("grant_type", GRANT_TYPE); + params.forEach(req::formParam); + return req.post("/token").then(); + } + protected RequestSpecification baseRequest() { return given() .port(PORT) @@ -132,6 +220,21 @@ protected RequestSpecification baseRequest() { .when(); } + private StsClient initClient(String clientSecret) { + var store = getClientStore(); + var vault = getVault(); + var clientId = "client_id"; + var clientSecretAlias = "client_secret_alias"; + var client = createClient(clientId, clientSecretAlias); + + + vault.storeSecret(clientSecretAlias, clientSecret); + vault.storeSecret(client.getPrivateKeyAlias(), loadResourceFile("ec-privatekey.pem")); + store.create(client); + + return client; + } + private StsClientStore getClientStore() { return sts.getContext().getService(StsClientStore.class); } @@ -143,9 +246,11 @@ private Vault getVault() { /** * Load content from a resource file. */ - private String loadResourceFile(String file) throws IOException { + private String loadResourceFile(String file) { try (var resourceAsStream = StsApiEndToEndTest.class.getClassLoader().getResourceAsStream(file)) { return new String(Objects.requireNonNull(resourceAsStream).readAllBytes()); + } catch (Exception e) { + throw new RuntimeException(e); } } }