From 06ddf3930fa359d3e96dafddf6cc8e548db1b2c9 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Fri, 29 Nov 2024 17:09:49 +0100 Subject: [PATCH] Improve Oracle Cloud Identity Domain OpenID Connect integration (#1873) --- .../validator/IssuerJwtClaimsValidator.java | 3 +- security-oauth2/build.gradle.kts | 2 + .../oauth2/DefaultProviderResolver.java | 3 +- .../oauth2/client/IdTokenClaimsValidator.java | 6 +- .../request/AuthorizationServer.java | 26 ++--- .../request/AuthorizationServerResolver.java | 38 ++++++++ .../DefaultAuthorizationServerResolver.java | 68 ++++++++++++++ .../request/EndSessionEndpointResolver.java | 22 ++++- .../client/IdTokenClaimsValidatorSpec.groovy | 27 ++++++ .../request/AuthorizationServerSpec.groovy | 1 + .../AuthorizationServerResolverTest.java | 39 ++++++++ .../OracleCloudEndSessionEndpointTest.java | 94 +++++++++++++++++++ security/build.gradle.kts | 1 + .../security/session/SessionIdResolver.java | 1 - .../micronaut/security/token/ClaimsUtils.java | 76 +++++++++++++++ .../security/token/ClaimsUtilsTest.java | 39 ++++++++ 16 files changed, 417 insertions(+), 29 deletions(-) create mode 100644 security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolver.java create mode 100644 security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/DefaultAuthorizationServerResolver.java create mode 100644 security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolverTest.java create mode 100644 security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/OracleCloudEndSessionEndpointTest.java create mode 100644 security/src/main/java/io/micronaut/security/token/ClaimsUtils.java create mode 100644 security/src/test/java/io/micronaut/security/token/ClaimsUtilsTest.java diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/IssuerJwtClaimsValidator.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/IssuerJwtClaimsValidator.java index 2931e5ba9c..db6377d397 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/IssuerJwtClaimsValidator.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/IssuerJwtClaimsValidator.java @@ -19,6 +19,7 @@ import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.security.token.Claims; +import io.micronaut.security.token.ClaimsUtils; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,7 +66,7 @@ public boolean validate(@NonNull Claims claims, @Nullable T request) { } return false; } - if (!expectedIssuer.equals(issuerObject.toString())) { + if (!ClaimsUtils.endsWithIgnoringProtocolAndTrailingSlash(expectedIssuer, issuerObject.toString())) { if (LOG.isTraceEnabled()) { LOG.trace("Expected JWT issuer claim of '{}', but found '{}' instead.", expectedIssuer, issuerObject); } diff --git a/security-oauth2/build.gradle.kts b/security-oauth2/build.gradle.kts index 2f235e0fa2..b24dbc42ea 100644 --- a/security-oauth2/build.gradle.kts +++ b/security-oauth2/build.gradle.kts @@ -34,6 +34,8 @@ dependencies { testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mnTest.micronaut.test.junit5) testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(platform(mnTest.boms.junit)) + testImplementation(libs.junit.jupiter.params) } tasks.withType { useJUnitPlatform() diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/DefaultProviderResolver.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/DefaultProviderResolver.java index 424c2e1926..e10beeb8bf 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/DefaultProviderResolver.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/DefaultProviderResolver.java @@ -21,6 +21,7 @@ import io.micronaut.security.oauth2.configuration.OpenIdClientConfiguration; import io.micronaut.security.oauth2.endpoint.token.response.OauthAuthenticationMapper; import io.micronaut.security.token.Claims; +import io.micronaut.security.token.ClaimsUtils; import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; @@ -67,7 +68,7 @@ protected Optional openIdClientNameWhichMatchesIssClaim(Authentication a protected Optional openIdClientNameWhichMatchesIssuer(@NonNull String issuer) { return openIdClientConfigurations.stream() .filter(conf -> conf.getIssuer().isPresent()) - .filter(conf -> conf.getIssuer().get().toString().startsWith(issuer)) + .filter(conf -> ClaimsUtils.endsWithIgnoringProtocolAndTrailingSlash(conf.getIssuer().get().toString(), issuer)) .map(Named::getName) .findFirst(); } diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/client/IdTokenClaimsValidator.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/client/IdTokenClaimsValidator.java index 73549193b2..e2f3b92452 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/client/IdTokenClaimsValidator.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/client/IdTokenClaimsValidator.java @@ -23,6 +23,7 @@ import io.micronaut.security.oauth2.configuration.OauthClientConfiguration; import io.micronaut.security.oauth2.configuration.OpenIdClientConfiguration; import io.micronaut.security.token.Claims; +import io.micronaut.security.token.ClaimsUtils; import io.micronaut.security.token.jwt.validator.GenericJwtClaimsValidator; import io.micronaut.security.token.jwt.validator.JwtClaimsValidatorConfigurationProperties; import jakarta.inject.Singleton; @@ -53,6 +54,7 @@ public class IdTokenClaimsValidator implements GenericJwtClaimsValidator { protected static final Logger LOG = LoggerFactory.getLogger(IdTokenClaimsValidator.class); protected static final String AUTHORIZED_PARTY = "azp"; + private static final String EMPTY = ""; protected final Collection oauthClientConfigurations; @@ -204,7 +206,7 @@ protected boolean validateIssuerAudienceAndAzp(@NonNull Claims claims, boolean matchesIssuer = matchesIssuer(openIdClientConfiguration, iss).orElse(false); if (!matchesIssuer) { if (LOG.isDebugEnabled()) { - LOG.debug("configuration issuer '{}' does not match claim issuer '{}'", openIdClientConfiguration.getIssuer().map(URL::toString).orElse(""), iss); + LOG.debug("configuration issuer '{}' does not match claim issuer '{}'", openIdClientConfiguration.getIssuer().map(URL::toString).orElse(EMPTY), iss); } return false; } @@ -237,7 +239,7 @@ protected Optional matchesIssuer(@NonNull OpenIdClientConfiguration ope @NonNull String iss) { return openIdClientConfiguration.getIssuer() .map(URL::toString) - .map(issuer -> issuer.equalsIgnoreCase(iss)); + .map(issuer -> ClaimsUtils.endsWithIgnoringProtocolAndTrailingSlash(issuer, iss)); } /** diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServer.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServer.java index ef41e161e3..19d2d48a98 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServer.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServer.java @@ -25,33 +25,19 @@ */ public enum AuthorizationServer { OKTA, + ORACLE_CLOUD, COGNITO, KEYCLOAK, AUTH0; - private static final String ISSUER_PART_OKTA = "okta"; - private static final String ISSUER_PART_COGNITO = "cognito"; - private static final String ISSUER_PART_AUTH0 = "auth0"; - private static final String ISSUER_PART_KEYCLOAK = "/auth/realms/"; - /** * @param issuer Issuer url - * @return An Authorization Server if it could be infered based on the contents of the issuer or empty if not - */ + * @return An Authorization Server if it could be inferred based on the contents of the issuer or empty if not + * @deprecated Use {@link AuthorizationServerResolver} instead + */ + @Deprecated @NonNull public static Optional infer(@NonNull String issuer) { - if (issuer.contains(ISSUER_PART_OKTA)) { - return Optional.of(AuthorizationServer.OKTA); - } - if (issuer.contains(ISSUER_PART_COGNITO)) { - return Optional.of(AuthorizationServer.COGNITO); - } - if (issuer.contains(ISSUER_PART_AUTH0)) { - return Optional.of(AuthorizationServer.AUTH0); - } - if (issuer.contains(ISSUER_PART_KEYCLOAK)) { - return Optional.of(AuthorizationServer.KEYCLOAK); - } - return Optional.empty(); + return Optional.ofNullable(DefaultAuthorizationServerResolver.infer(issuer)); } } diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolver.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolver.java new file mode 100644 index 0000000000..2d09718b9f --- /dev/null +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolver.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.oauth2.endpoint.endsession.request; + +import io.micronaut.context.annotation.DefaultImplementation; +import io.micronaut.core.annotation.NonNull; + +import java.util.Optional; + +/** + * API to resolve an {@link AuthorizationServer} based on the issuer url. + * @author Sergio del Amo + * @since 4.12.0 + */ +@FunctionalInterface +@DefaultImplementation(DefaultAuthorizationServerResolver.class) +public interface AuthorizationServerResolver { + /** + * + * @param issuer OpenID Authorization Server issuer + * @return Based on substrings of the issuer url, it returns an {@link AuthorizationServer}. + */ + @NonNull + Optional resolve(@NonNull String issuer); +} diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/DefaultAuthorizationServerResolver.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/DefaultAuthorizationServerResolver.java new file mode 100644 index 0000000000..15a599d585 --- /dev/null +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/DefaultAuthorizationServerResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.oauth2.endpoint.endsession.request; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import jakarta.inject.Singleton; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link io.micronaut.context.annotation.DefaultImplementation} of {@link AuthorizationServerResolver}. + * Based on substrings of the issuer url it returns an {@link AuthorizationServer}. + * @author Sergio del Amo + * @since 4.12.0 + */ +@Internal +@Singleton +final class DefaultAuthorizationServerResolver implements AuthorizationServerResolver { + private static final String ISSUER_PART_OKTA = "okta"; + private static final String ISSUER_PART_ORACLE_CLOUD = "oraclecloud"; + private static final String ISSUER_PART_COGNITO = "cognito"; + private static final String ISSUER_PART_AUTH0 = "auth0"; + private static final String ISSUER_PART_KEYCLOAK = "/auth/realms/"; + private static final Map cache = new ConcurrentHashMap<>(); + + @Override + @NonNull + public Optional resolve(@NonNull String issuer) { + return Optional.ofNullable(cache.computeIfAbsent(issuer, DefaultAuthorizationServerResolver::infer)); + } + + @Nullable + static AuthorizationServer infer (@NonNull String issuer) { + if (issuer.contains(ISSUER_PART_ORACLE_CLOUD)) { + return AuthorizationServer.ORACLE_CLOUD; + } + if (issuer.contains(ISSUER_PART_OKTA)) { + return AuthorizationServer.OKTA; + } + if (issuer.contains(ISSUER_PART_COGNITO)) { + return AuthorizationServer.COGNITO; + } + if (issuer.contains(ISSUER_PART_AUTH0)) { + return AuthorizationServer.AUTH0; + } + if (issuer.contains(ISSUER_PART_KEYCLOAK)) { + return AuthorizationServer.KEYCLOAK; + } + return null; + } +} diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/EndSessionEndpointResolver.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/EndSessionEndpointResolver.java index 1500f1f8d9..54d55b1568 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/EndSessionEndpointResolver.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/endsession/request/EndSessionEndpointResolver.java @@ -24,6 +24,7 @@ import io.micronaut.security.oauth2.configuration.OpenIdClientConfiguration; import io.micronaut.security.oauth2.endpoint.endsession.response.EndSessionCallbackUrlBuilder; import io.micronaut.security.token.reader.TokenResolver; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.net.URL; import java.util.Optional; @@ -43,12 +44,24 @@ public class EndSessionEndpointResolver { private static final Logger LOG = LoggerFactory.getLogger(EndSessionEndpointResolver.class); private final BeanContext beanContext; + private final AuthorizationServerResolver authorizationServerResolver; /** * @param beanContext The bean context + * @param authorizationServerResolver The authorization server resolver */ - public EndSessionEndpointResolver(BeanContext beanContext) { + @Inject + public EndSessionEndpointResolver(BeanContext beanContext, AuthorizationServerResolver authorizationServerResolver) { this.beanContext = beanContext; + this.authorizationServerResolver = authorizationServerResolver; + } + + /** + * @param beanContext The bean context + */ + @Deprecated + public EndSessionEndpointResolver(BeanContext beanContext) { + this(beanContext, new DefaultAuthorizationServerResolver()); } /** @@ -120,7 +133,7 @@ private Optional getEndSessionEndpoint(OauthClientConfigurat EndSessionCallbackUrlBuilder endSessionCallbackUrlBuilder, @NonNull String providerName, @NonNull String issuer) { - Optional inferOptional = AuthorizationServer.infer(issuer); + Optional inferOptional = authorizationServerResolver.resolve(issuer); if (!inferOptional.isPresent()) { if (LOG.isDebugEnabled()) { LOG.debug("No EndSessionEndpoint can be resolved. The issuer for provider [{}] does not match any of the providers supported by default", providerName); @@ -128,9 +141,10 @@ private Optional getEndSessionEndpoint(OauthClientConfigurat return Optional.empty(); } switch (inferOptional.get()) { - case OKTA: + // Oracle Cloud Logout https://docs.oracle.com/en/cloud/paas/identity-cloud/rest-api/op-oauth2-v1-userlogout-get.html + case ORACLE_CLOUD, OKTA: if (LOG.isDebugEnabled()) { - LOG.debug("Resolved the OktaEndSessionEndpoint for provider [{}]", providerName); + LOG.debug("Resolved auth server {} for provider [{}]", inferOptional.get(), providerName); } return oktaEndSessionEndpoint(oauthClientConfiguration, openIdProviderMetadata, endSessionCallbackUrlBuilder); case COGNITO: diff --git a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/IdTokenClaimsValidatorSpec.groovy b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/IdTokenClaimsValidatorSpec.groovy index f85fa32edb..ddde56a09b 100644 --- a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/IdTokenClaimsValidatorSpec.groovy +++ b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/IdTokenClaimsValidatorSpec.groovy @@ -1,6 +1,9 @@ package io.micronaut.security.oauth2.client +import io.micronaut.security.oauth2.configuration.OauthClientConfiguration +import io.micronaut.security.oauth2.configuration.OauthClientConfigurationProperties import io.micronaut.security.testutils.ApplicationContextSpecification +import spock.lang.Unroll class IdTokenClaimsValidatorSpec extends ApplicationContextSpecification { @@ -8,4 +11,28 @@ class IdTokenClaimsValidatorSpec extends ApplicationContextSpecification { expect: !applicationContext.containsBean(IdTokenClaimsValidator) } + + @Unroll + void "issuer IdTokenClaimsValidator"(String configIss, String iss) { + given: + def oauthClientConfiguration = new OauthClientConfigurationProperties("oci"); + def openId = new OauthClientConfigurationProperties.OpenIdClientConfigurationProperties("oci"); + openId.setIssuer(new URL(configIss)) + oauthClientConfiguration.setOpenid(openId) + List l = List.of(oauthClientConfiguration) + IdTokenClaimsValidator claimsValidator = new IdTokenClaimsValidator(List.of(l)) + + when: + Optional validation = claimsValidator.matchesIssuer(openId, iss) + + then: + validation.isPresent() + validation.get() == true + + where: + configIss | iss + "https://idcs-227ebfb7094445cc5a3fbc0faa1fe87b.identity.oraclecloud.com" | "https://identity.oraclecloud.com/" + "https://idcs-227ebfb7094445cc5a3fbc0faa1fe87b.identity.oraclecloud.com" | "https://identity.oraclecloud.com" + "https://identity.oraclecloud.com" | "https://identity.oraclecloud.com" + } } diff --git a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerSpec.groovy b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerSpec.groovy index a2718ebbe7..5c860ec5f9 100644 --- a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerSpec.groovy +++ b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerSpec.groovy @@ -16,6 +16,7 @@ class AuthorizationServerSpec extends Specification { "https://dev-XXXXX.oktapreview.com/oauth2/default" || AuthorizationServer.OKTA "https://cognito-idp.us-east-1.amazonaws.com/12345}/" || AuthorizationServer.COGNITO "https://micronautguides.eu.auth0.com" || AuthorizationServer.AUTH0 + "https://identity.oraclecloud.com/" || AuthorizationServer.ORACLE_CLOUD } void "Infer authorization server based on the issuer url may return empty Optional"() { diff --git a/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolverTest.java b/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolverTest.java new file mode 100644 index 0000000000..df144c1941 --- /dev/null +++ b/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/AuthorizationServerResolverTest.java @@ -0,0 +1,39 @@ +package io.micronaut.security.oauth2.endpoint.endsession.request; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +@MicronautTest(startApplication = false) +class AuthorizationServerResolverTest { + + @Inject + AuthorizationServerResolver resolver; + + static Stream paramsProvider() { + return Stream.of( + arguments("http://localhost:8180/auth/realms/master", AuthorizationServer.KEYCLOAK), + arguments("https://dev-XXXXX.oktapreview.com/oauth2/default", AuthorizationServer.OKTA), + arguments("https://cognito-idp.us-east-1.amazonaws.com/12345}/", AuthorizationServer.COGNITO), + arguments("https://micronautguides.eu.auth0.com", AuthorizationServer.AUTH0), + arguments("https://identity.oraclecloud.com/", AuthorizationServer.ORACLE_CLOUD)); + } + + @ParameterizedTest + @MethodSource("paramsProvider") + void inferAuthorizationServer(String issuer, AuthorizationServer authorizationServer) { + assertTrue(resolver.resolve(issuer).isPresent()); + assertEquals(authorizationServer, resolver.resolve(issuer).get()); + } + + @Test + void inferAuthorizationServerBasedOnTheIssuerUrlMayReturnEmptyOptional() { + assertFalse(resolver.resolve("http://localhost:8180/auth").isPresent()); + } +} \ No newline at end of file diff --git a/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/OracleCloudEndSessionEndpointTest.java b/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/OracleCloudEndSessionEndpointTest.java new file mode 100644 index 0000000000..2fa71ab48f --- /dev/null +++ b/security-oauth2/src/test/java/io/micronaut/security/oauth2/endpoint/endsession/request/OracleCloudEndSessionEndpointTest.java @@ -0,0 +1,94 @@ +package io.micronaut.security.oauth2.endpoint.endsession.request; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.inject.qualifiers.Qualifiers; +import io.micronaut.runtime.server.EmbeddedServer; +import io.micronaut.security.oauth2.client.OpenIdClient; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OracleCloudEndSessionEndpointTest { + private static final String OPENID_CONFIG = """ + {"issuer":"https://identity.oraclecloud.com/", + "authorization_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/authorize", + "token_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/token", + "userinfo_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/userinfo", + "revocation_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/revoke", + "introspection_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/introspect", + "end_session_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/oauth2/v1/userlogout", + "secure_authorization_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/authorize", + "secure_token_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/token", + "secure_userinfo_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/userinfo", + "secure_revocation_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/revoke", + "secure_introspection_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/introspect", + "secure_end_session_endpoint":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/oauth2/v1/userlogout", + "jwks_uri":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com:443/admin/v1/SigningCert/jwk", + "secure_jwks_uri":"https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.eu-madrid-idcs-1.secure.identity.oraclecloud.com/admin/v1/SigningCert/jwk", + "scopes_supported":["openid", "profile", "offline_access", "email", "address", "phone", "groups", "get_groups", "approles", "get_approles"], + "response_types_supported":["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token"], + "subject_types_supported":["public"], + "id_token_signing_alg_values_supported":["RS256"], + "claims_supported":["aud", "exp", "iat", "iss", "jti", "sub"], + "grant_types_supported":["client_credentials", "password", "refresh_token", "authorization_code", "urn:ietf:params:oauth:grant-type:jwt-bearer", "tls_cert_auth"], + "token_endpoint_auth_methods_supported":["client_secret_basic", "private_key_jwt", "client_secret_post"], + "token_endpoint_auth_signing_alg_values_supported":["RS256"], + "userinfo_signing_alg_values_supported":["none"], + "ui_locales_supported":["en"], + "claims_parameter_supported":false, + "http_logout_supported":true, + "logout_session_supported":false, + "request_parameter_supported":false, + "request_uri_parameter_supported":false, + "require_request_uri_registration":false, + "idcs_id_token":"supported", + "idcs_logout_v3":"supported" +} + """; + + @Test + void oracleCloudConfigurationSupportsEndSession() { + String nameQualifier = "oci"; + try (EmbeddedServer authServer = ApplicationContext.run(EmbeddedServer.class, + Map.of("spec.name", "OracleCloudEndSessionEndpointTestAuthServer"))) { + + try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, + Map.of("spec.name", "OracleCloudEndSessionEndpointTest", + "micronaut.security.oauth2.clients." + nameQualifier + ".openid.issuer", authServer.getURL().toString(), + "micronaut.security.oauth2.clients." + nameQualifier + ".client-secret", "yyy", + "micronaut.security.oauth2.clients." + nameQualifier + ".client-id", "xxx" + ))) { + + OpenIdClient openIdClient = server.getApplicationContext().getBean(OpenIdClient.class, Qualifiers.byName(nameQualifier)); + assertTrue(openIdClient.supportsEndSession()); + } + } + } + + @Requires(property = "spec.name", value = "OracleCloudEndSessionEndpointTest") + @Singleton + @Replaces(AuthorizationServerResolver.class) + static class AuthorizationServerResolverReplacement implements AuthorizationServerResolver { + @Override + public Optional resolve(String issuer) { + return Optional.of(AuthorizationServer.ORACLE_CLOUD); + } + } + + @Requires(property = "spec.name", value = "OracleCloudEndSessionEndpointTestAuthServer") + @Controller + static class OpenidConfigurationController { + @Get("/.well-known/openid-configuration") + String index() { + return OPENID_CONFIG; + } + } +} \ No newline at end of file diff --git a/security/build.gradle.kts b/security/build.gradle.kts index e16aa3ff01..5da28b1bee 100644 --- a/security/build.gradle.kts +++ b/security/build.gradle.kts @@ -37,5 +37,6 @@ dependencies { testAnnotationProcessor(mn.micronaut.inject.java) testImplementation(mnTest.micronaut.test.junit5) + testImplementation(libs.junit.jupiter.params) testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java b/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java index 0a6ea05189..46ab89d235 100644 --- a/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java +++ b/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java @@ -19,7 +19,6 @@ import io.micronaut.core.annotation.Indexed; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.order.Ordered; -import io.micronaut.data.annotation.Index; import io.micronaut.security.config.SecurityConfigurationProperties; import java.util.Optional; diff --git a/security/src/main/java/io/micronaut/security/token/ClaimsUtils.java b/security/src/main/java/io/micronaut/security/token/ClaimsUtils.java new file mode 100644 index 0000000000..af89894b3b --- /dev/null +++ b/security/src/main/java/io/micronaut/security/token/ClaimsUtils.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class to compare claims. + * @author Sergio del Amo + * @since 4.12.0 + */ +@Internal +public final class ClaimsUtils { + private static final String HTTP = "http://"; + private static final String HTTPS = "https://"; + private static final String EMPTY = ""; + private static final String SLASH = "/"; + + private static final Map cache = new HashMap<>(); + + private ClaimsUtils() { + } + + /** + * For example, for input {@code "https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com"} and {@code identity.oraclecloud.com}, it returns {@code true}. + * @param expectedClaim Expected Claim + * @param claim Claim + * @return Whether the expected claim ends with the supplied claim. Both claims are compared without leading protocol and trailing slash. + */ + public static boolean endsWithIgnoringProtocolAndTrailingSlash(@NonNull String expectedClaim, @NonNull String claim) { + ClaimPair pair = new ClaimPair(expectedClaim, claim); + return cache.computeIfAbsent(pair, claimPair -> + removeLeadingProtocolAndTrailingSlash(claimPair.expectedClaim()) + .endsWith(removeLeadingProtocolAndTrailingSlash(claimPair.claim()))); + } + + /** + * For example for input {@code https://identity.oraclecloud.com/}, it returns {@code identity.oraclecloud.com}. + * @param claim Token Claim + * @return Token Claim without leading protocol and trailing slash + */ + @NonNull + static String removeLeadingProtocolAndTrailingSlash(@NonNull String claim) { + return removeTrailingSlash(removeProtocol(claim)); + } + + @NonNull + private static String removeTrailingSlash(@NonNull String iss) { + return iss.endsWith(SLASH) ? iss.substring(0, iss.length() - SLASH.length()) : iss; + } + + @NonNull + private static String removeProtocol(@NonNull String iss) { + return iss.replace(HTTP, EMPTY) + .replace(HTTPS, EMPTY); + } + + record ClaimPair(String expectedClaim, String claim) { + } +} diff --git a/security/src/test/java/io/micronaut/security/token/ClaimsUtilsTest.java b/security/src/test/java/io/micronaut/security/token/ClaimsUtilsTest.java new file mode 100644 index 0000000000..aaae916744 --- /dev/null +++ b/security/src/test/java/io/micronaut/security/token/ClaimsUtilsTest.java @@ -0,0 +1,39 @@ +package io.micronaut.security.token; + +import junit.framework.TestCase; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +public class ClaimsUtilsTest extends TestCase { + + static Stream paramsProvider() { + return Stream.of( + arguments("https://identity.oraclecloud.com", "identity.oraclecloud.com"), + arguments("https://identity.oraclecloud.com/", "identity.oraclecloud.com"), + arguments("http://identity.oraclecloud.com/", "identity.oraclecloud.com"), + arguments("identity.oraclecloud.com", "identity.oraclecloud.com")); + } + + static Stream startsWithParamsProvider() { + return Stream.of( + arguments("https://idcs-214ecfa9143532ca8c3fba0ecb1fe65b.identity.oraclecloud.com", "https://identity.oraclecloud.com/") + ); + } + + @ParameterizedTest + @MethodSource("startsWithParamsProvider") + void endsWithIgnoring(String configClaim, String claim) { + assertTrue(ClaimsUtils.endsWithIgnoringProtocolAndTrailingSlash(configClaim, claim)); + } + + @ParameterizedTest + @MethodSource("paramsProvider") + void cleanupClaim(String claim, String expected) { + assertEquals(expected, ClaimsUtils.removeLeadingProtocolAndTrailingSlash(claim)); + } +} \ No newline at end of file