diff --git a/extensions/common/iam/oauth2/oauth2-core/README.md b/extensions/common/iam/oauth2/oauth2-core/README.md index e98bf54e209..51dc5ee866a 100644 --- a/extensions/common/iam/oauth2/oauth2-core/README.md +++ b/extensions/common/iam/oauth2/oauth2-core/README.md @@ -4,17 +4,18 @@ This extension provides an `IdentityService` implementation based on the OAuth2 ## Configuration -| Parameter name | Description | Mandatory | Default value | -|:----------------------------------|:-------------------------------------------------------------------------------------------|:----------|:------------------------------------| -| `edc.oauth.token.url` | URL of the authorization server | true | null | -| `edc.oauth.provider.audience` | Provider audience to be put in the outgoing token as 'aud' claim | false | id of the connector | -| `edc.oauth.endpoint.audience` | Endpoint audience to verify incoming token 'aud' claim | false | `edc.oauth.provider.audience` value | -| `edc.oauth.provider.jwks.url` | URL from which well-known public keys of Authorization server can be fetched | false | http://localhost/empty_jwks_url | -| `edc.oauth.certificate.alias` | Alias of public associated with client certificate | true | null | -| `edc.oauth.private.key.alias` | Alias of private key (used to sign the token) | true | null | -| `edc.oauth.provider.jwks.refresh` | Interval at which public keys are refreshed from Authorization server (in minutes) | false | 5 | -| `edc.oauth.client.id` | Public identifier of the client | true | null | -| `edc.oauth.validation.nbf.leeway` | Leeway in seconds added to current time to remedy clock skew on notBefore claim validation | false | 10 | +| Parameter name | Description | Mandatory | Default value | +|:-----------------------------------|:---------------------------------------------------------------------------------------------------------------------|:----------|:------------------------------------| +| `edc.oauth.token.url` | URL of the authorization server | true | null | +| `edc.oauth.provider.audience` | Provider audience to be put in the outgoing token as 'aud' claim | false | id of the connector | +| `edc.oauth.endpoint.audience` | Endpoint audience to verify incoming token 'aud' claim | false | `edc.oauth.provider.audience` value | +| `edc.oauth.provider.jwks.url` | URL from which well-known public keys of Authorization server can be fetched | false | http://localhost/empty_jwks_url | +| `edc.oauth.certificate.alias` | Alias of public associated with client certificate | true | null | +| `edc.oauth.private.key.alias` | Alias of private key (used to sign the token) | true | null | +| `edc.oauth.provider.jwks.refresh` | Interval at which public keys are refreshed from Authorization server (in minutes) | false | 5 | +| `edc.oauth.client.id` | Public identifier of the client | true | null | +| `edc.oauth.validation.nbf.leeway` | Leeway in seconds added to current time to remedy clock skew on notBefore claim validation | false | 10 | +| `edc.oauth.token.resource.enabled` | Adds `resource` form parameter in the access token request. Allows to specify an audience as defined in the RFC-8707 | false | false | ## Extensions diff --git a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceConfiguration.java b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceConfiguration.java index f28200348b5..1a9d1fbcb96 100644 --- a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceConfiguration.java +++ b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceConfiguration.java @@ -52,6 +52,9 @@ public class Oauth2ServiceConfiguration { @Setting(description = "Token expiration in minutes. By default is 5 minutes", key = "edc.oauth.token.expiration", defaultValue = DEFAULT_TOKEN_EXPIRATION + "") private Long tokenExpiration; + @Setting(description = "Enable the connector to request a token with a specific audience as defined in the RFC-8707.", key = "edc.oauth.token.resource.enabled", defaultValue = "false") + private boolean tokenResourceEnabled; + private Oauth2ServiceConfiguration() { } @@ -92,6 +95,10 @@ public Long getTokenExpiration() { return tokenExpiration; } + public boolean isTokenResourceEnabled() { + return tokenResourceEnabled; + } + public int getProviderJwksRefresh() { return providerJwksRefresh; } @@ -161,6 +168,11 @@ public Builder tokenExpiration(long tokenExpiration) { return this; } + public Builder tokenResourceEnabled(boolean tokenResourceEnabled) { + configuration.tokenResourceEnabled = tokenResourceEnabled; + return this; + } + public Oauth2ServiceConfiguration build() { return configuration; } diff --git a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceExtension.java b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceExtension.java index 94aa811778a..3cb935998c7 100644 --- a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceExtension.java +++ b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/Oauth2ServiceExtension.java @@ -159,7 +159,8 @@ private Oauth2ServiceImpl createOauth2Service(Oauth2ServiceConfiguration configu jwtDecoratorRegistry, tokenValidationRulesRegistry, tokenValidationService, - providerKeyResolver + providerKeyResolver, + configuration.isTokenResourceEnabled() ); } diff --git a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImpl.java b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImpl.java index 6c0eb800e38..3f86b3f65c0 100644 --- a/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImpl.java +++ b/extensions/common/iam/oauth2/oauth2-core/src/main/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImpl.java @@ -20,6 +20,7 @@ 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.PrivateKeyOauth2CredentialsRequest; +import org.eclipse.edc.iam.oauth2.spi.client.PrivateKeyOauth2CredentialsRequest.Builder; import org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames; import org.eclipse.edc.keys.spi.PublicKeyResolver; import org.eclipse.edc.spi.iam.ClaimToken; @@ -54,6 +55,7 @@ public class Oauth2ServiceImpl implements IdentityService { private final TokenValidationService tokenValidationService; private final PublicKeyResolver publicKeyResolver; private final TokenValidationRulesRegistry tokenValidationRuleRegistry; + private final boolean tokenResourceEnabled; /** * Creates a new instance of the OAuth2 Service @@ -63,10 +65,11 @@ public class Oauth2ServiceImpl implements IdentityService { * @param client client for Oauth2 server * @param jwtDecoratorRegistry Registry containing the decorator for build the JWT * @param tokenValidationService Service used for token validation + * @param tokenResourceEnabled Add support for generating access token request with resource parameter */ public Oauth2ServiceImpl(String tokenUrl, TokenGenerationService tokenGenerationService, Supplier privateKeyIdSupplier, Oauth2Client client, TokenDecoratorRegistry jwtDecoratorRegistry, TokenValidationRulesRegistry tokenValidationRuleRegistry, TokenValidationService tokenValidationService, - PublicKeyResolver publicKeyResolver) { + PublicKeyResolver publicKeyResolver, boolean tokenResourceEnabled) { this.tokenUrl = tokenUrl; this.privateKeySupplier = privateKeyIdSupplier; this.client = client; @@ -75,6 +78,7 @@ public Oauth2ServiceImpl(String tokenUrl, TokenGenerationService tokenGeneration this.tokenGenerationService = tokenGenerationService; this.tokenValidationService = tokenValidationService; this.publicKeyResolver = publicKeyResolver; + this.tokenResourceEnabled = tokenResourceEnabled; } @Override @@ -98,12 +102,16 @@ private Result generateClientAssertion() { @NotNull private Oauth2CredentialsRequest createRequest(TokenParameters parameters, String assertion) { - return PrivateKeyOauth2CredentialsRequest.Builder.newInstance() + PrivateKeyOauth2CredentialsRequest.Builder builder = Builder.newInstance() .url(tokenUrl) .clientAssertion(assertion) .scope(parameters.getStringClaim(JwtRegisteredClaimNames.SCOPE)) - .grantType(GRANT_TYPE) - .build(); + .grantType(GRANT_TYPE); + + if (tokenResourceEnabled) { + builder.resource(parameters.getStringClaim(JwtRegisteredClaimNames.AUDIENCE)); + } + return builder.build(); } } diff --git a/extensions/common/iam/oauth2/oauth2-core/src/test/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImplTest.java b/extensions/common/iam/oauth2/oauth2-core/src/test/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImplTest.java index ec09c37f048..c30c6837184 100644 --- a/extensions/common/iam/oauth2/oauth2-core/src/test/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImplTest.java +++ b/extensions/common/iam/oauth2/oauth2-core/src/test/java/org/eclipse/edc/iam/oauth2/identity/Oauth2ServiceImplTest.java @@ -101,6 +101,7 @@ void setUp() throws JOSEException { .publicCertificateAlias(PUBLIC_CERTIFICATE_ALIAS) .providerAudience(PROVIDER_AUDIENCE) .endpointAudience(ENDPOINT_AUDIENCE) + .tokenResourceEnabled(true) .build(); var tokenValidationService = new TokenValidationServiceImpl(); @@ -114,7 +115,7 @@ void setUp() throws JOSEException { registry.addRule(OAUTH2_TOKEN_CONTEXT, new ExpirationIssuedAtValidationRule(Clock.systemUTC(), configuration.getIssuedAtLeeway())); authService = new Oauth2ServiceImpl(configuration.getTokenUrl(), tokenGenerationService, () -> TEST_PRIVATE_KEY_ID, client, jwtDecoratorRegistry, registry, - tokenValidationService, publicKeyResolverMock); + tokenValidationService, publicKeyResolverMock, configuration.isTokenResourceEnabled()); } @@ -143,6 +144,7 @@ void obtainClientCredentials() { assertThat(capturedRequest.getScope()).isEqualTo("scope"); assertThat(capturedRequest.getClientAssertion()).isEqualTo("assertionToken"); assertThat(capturedRequest.getClientAssertionType()).isEqualTo("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + assertThat(capturedRequest.getResource()).isEqualTo("audience"); } diff --git a/extensions/common/iam/oauth2/oauth2-daps/src/test/java/org/eclipse/edc/iam/oauth2/daps/Rfc8707IntegrationTest.java b/extensions/common/iam/oauth2/oauth2-daps/src/test/java/org/eclipse/edc/iam/oauth2/daps/Rfc8707IntegrationTest.java new file mode 100644 index 00000000000..446e7650899 --- /dev/null +++ b/extensions/common/iam/oauth2/oauth2-daps/src/test/java/org/eclipse/edc/iam/oauth2/daps/Rfc8707IntegrationTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 Microsoft Corporation + * + * 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: + * Amadeus - initial API and implementation + * Microsoft Corporation - Use IDS Webhook address for JWT audience claim + * + */ + +package org.eclipse.edc.iam.oauth2.daps; + +import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.junit.extensions.EdcExtension; +import org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames; +import org.eclipse.edc.policy.model.Policy; +import org.eclipse.edc.spi.iam.IdentityService; +import org.eclipse.edc.spi.iam.TokenParameters; +import org.eclipse.edc.spi.iam.VerificationContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; + +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.testfixtures.TestUtils.findBuildRoot; + +@ExtendWith(EdcExtension.class) +@ComponentTest +public class Rfc8707IntegrationTest { + + public static final String CLIENT_CERTIFICATE_ALIAS = "1"; + public static final String CLIENT_PRIVATE_KEY_ALIAS = "2"; + private static final String AUDIENCE_IDS_CONNECTORS_ALL = "idsc:IDS_CONNECTORS_ALL"; + private static final String AUDIENCE_CONNECTOR = "audience"; + private static final String CLIENT_ID = "68:99:2E:D4:13:2D:FD:3A:66:6B:85:DE:FB:98:2E:2D:FD:E7:83:D7"; + private static final String CLIENT_KEYSTORE_PASSWORD = "1234"; + + private final Path resourceFolder = findBuildRoot().toPath().resolve("extensions/common/iam/oauth2/oauth2-daps/src/test/resources"); + + @Container + private final GenericContainer daps = new GenericContainer<>("ghcr.io/fraunhofer-aisec/omejdn-server:1.4.2") + .withExposedPorts(4567) + .withFileSystemBind(resourceFolder.resolve("config").toString(), "/opt/config") + .withFileSystemBind(resourceFolder.resolve("keys").toString(), "/opt/keys"); + + /** + * Verify that a connector with support for the RFC-8707 is able to set its audience via resource parameter. + * + * @param identityService The identity service used to obtains credentials + */ + @Test + void retrieveTokenAndValidate(IdentityService identityService) { + var tokenParameters = TokenParameters.Builder.newInstance() + .claims(JwtRegisteredClaimNames.SCOPE, "idsc:IDS_CONNECTOR_ATTRIBUTES_ALL") + .claims(JwtRegisteredClaimNames.AUDIENCE, AUDIENCE_CONNECTOR) + .build(); + var tokenResult = identityService.obtainClientCredentials(tokenParameters); + + assertThat(tokenResult.succeeded()).withFailMessage(tokenResult::getFailureDetail).isTrue(); + + var verificationContext = VerificationContext.Builder.newInstance() + .policy(Policy.Builder.newInstance().build()) + .build(); + + var verificationResult = identityService.verifyJwtToken(tokenResult.getContent(), verificationContext); + + assertThat(verificationResult.succeeded()).withFailMessage(verificationResult::getFailureDetail).isTrue(); + } + + @BeforeEach + protected void before(EdcExtension extension) { + System.setProperty("edc.keystore", "src/test/resources/keystore.p12"); + System.setProperty("edc.keystore.password", CLIENT_KEYSTORE_PASSWORD); + + var jwksPath = "/.well-known/jwks.json"; + daps.waitingFor(Wait.forHttp(jwksPath)).start(); + + var dapsUrl = "http://%s:%s".formatted(daps.getHost(), daps.getFirstMappedPort()); + + extension.setConfiguration(Map.of( + "edc.oauth.token.url", dapsUrl + "/token", + "edc.oauth.client.id", CLIENT_ID, + "edc.oauth.provider.audience", AUDIENCE_IDS_CONNECTORS_ALL, + "edc.oauth.endpoint.audience", AUDIENCE_CONNECTOR, + "edc.oauth.provider.jwks.url", dapsUrl + jwksPath, + "edc.oauth.certificate.alias", CLIENT_CERTIFICATE_ALIAS, + "edc.oauth.private.key.alias", CLIENT_PRIVATE_KEY_ALIAS, + "edc.iam.token.scope", "idsc:IDS_CONNECTOR_ATTRIBUTES_ALL", + "edc.oauth.token.resource.enable", "true" + )); + } + +} diff --git a/spi/common/oauth2-spi/src/main/java/org/eclipse/edc/iam/oauth2/spi/client/Oauth2CredentialsRequest.java b/spi/common/oauth2-spi/src/main/java/org/eclipse/edc/iam/oauth2/spi/client/Oauth2CredentialsRequest.java index 19e02050842..aa254e0a157 100644 --- a/spi/common/oauth2-spi/src/main/java/org/eclipse/edc/iam/oauth2/spi/client/Oauth2CredentialsRequest.java +++ b/spi/common/oauth2-spi/src/main/java/org/eclipse/edc/iam/oauth2/spi/client/Oauth2CredentialsRequest.java @@ -25,6 +25,7 @@ public abstract class Oauth2CredentialsRequest { private static final String GRANT_TYPE = "grant_type"; private static final String SCOPE = "scope"; + private static final String RESOURCE = "resource"; protected String url; protected final Map params = new HashMap<>(); @@ -44,6 +45,15 @@ public String getGrantType() { return params.get(GRANT_TYPE); } + /** + * The audience for which an access token will be requested. + * @return The value of the resource form parameter. + */ + @Nullable + public String getResource() { + return this.params.get(RESOURCE); + } + public Map getParams() { return params; } @@ -80,6 +90,17 @@ public B params(Map params) { return self(); } + /** + * Adds the resource form parameter to the request. + * + * @param targetedAudience The audience for which an access token will be requested. + * @see RFC-8707 + * @return this builder + */ + public B resource(String targetedAudience) { + return param(RESOURCE, targetedAudience); + } + public abstract B self(); protected T build() { @@ -87,5 +108,6 @@ protected T build() { Objects.requireNonNull(request.params.get(GRANT_TYPE), GRANT_TYPE); return request; } + } }