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/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 017547e0c0..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,8 +54,6 @@ 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 HTTP = "http://"; - private static final String HTTPS = "https://"; private static final String EMPTY = ""; protected final Collection oauthClientConfigurations; @@ -238,16 +237,9 @@ protected boolean validateIssuerAudienceAndAzp(@NonNull Claims claims, @NonNull protected Optional matchesIssuer(@NonNull OpenIdClientConfiguration openIdClientConfiguration, @NonNull String iss) { - String issWithoutProtocol = removeProtocol(iss); return openIdClientConfiguration.getIssuer() .map(URL::toString) - .map(IdTokenClaimsValidator::removeProtocol) - .map(issuer -> issuer.endsWith(issWithoutProtocol)); - } - - private static String removeProtocol(String iss) { - return iss.replace(HTTP, EMPTY) - .replace(HTTPS, EMPTY); + .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..7f9fbd746d 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,11 +25,13 @@ */ public enum AuthorizationServer { OKTA, + ORACLE_CLOUD, COGNITO, KEYCLOAK, AUTH0; 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/"; @@ -40,6 +42,9 @@ public enum AuthorizationServer { */ @NonNull public static Optional infer(@NonNull String issuer) { + if (issuer.contains(ISSUER_PART_ORACLE_CLOUD)) { + return Optional.of(AuthorizationServer.ORACLE_CLOUD); + } if (issuer.contains(ISSUER_PART_OKTA)) { return Optional.of(AuthorizationServer.OKTA); } 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..74670326ac 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 @@ -128,9 +128,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/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/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..c1f058cdfa --- /dev/null +++ b/security/src/main/java/io/micronaut/security/token/ClaimsUtils.java @@ -0,0 +1,66 @@ +/* + * 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; + +/** + * 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 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) { + return removeLeadingProtocolAndTrailingSlash(expectedClaim).endsWith(removeLeadingProtocolAndTrailingSlash(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() - 1) : iss; + } + + @NonNull + private static String removeProtocol(@NonNull String iss) { + return iss.replace(HTTP, EMPTY) + .replace(HTTPS, EMPTY); + } +} 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..f7f100407a --- /dev/null +++ b/security/src/test/java/io/micronaut/security/token/ClaimsUtilsTest.java @@ -0,0 +1,38 @@ +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