diff --git a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/AuthenticationUtils.kt b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/AuthenticationUtils.kt index a68ca45..7d0c859 100644 --- a/gooddata-server-oauth2-autoconfigure/src/main/kotlin/AuthenticationUtils.kt +++ b/gooddata-server-oauth2-autoconfigure/src/main/kotlin/AuthenticationUtils.kt @@ -160,27 +160,35 @@ private fun dexClientRegistration( private fun handleAzureB2CClientRegistration( issuerLocation: String ): ClientRegistration.Builder { - val uri = buildMetadataUri(issuerLocation) - val configuration = retrieveOidcConfiguration(uri) + val issuerUri = URI.create(issuerLocation) + val metadataUri = buildMetadataUri(issuerUri) + val configuration = retrieveOidcConfiguration(metadataUri) - return if (isValidAzureB2CMetadata(configuration, uri)) { + val validationResult = validateAzureB2CMetadata(configuration, issuerUri) + return if (validationResult.isValid) { fromOidcConfiguration(configuration) } else { + val mismatches = validationResult.mismatchedEndpoints.entries.joinToString(separator = "\n") { + "${it.key}: ${it.value}" + } throw ResponseStatusException( HttpStatus.UNAUTHORIZED, - "Authorization failed for given issuer \"$issuerLocation\". Metadata endpoints do not match." + """ + Authorization failed for the given issuer "$issuerLocation". + Metadata endpoints do not match the configured issuer location. Mismatched endpoints: + $mismatches + """.trimIndent() ) } } /** - * Builds metadata retrieval URI based on the provided [issuerLocation]. + * Builds metadata retrieval URI based on the provided [issuer]. * - * @param issuerLocation The issuer location URL as a string. + * @param issuer The issuer location URI. * @return The constructed [URI] for metadata retrieval. */ -internal fun buildMetadataUri(issuerLocation: String): URI { - val issuer = URI.create(issuerLocation) +internal fun buildMetadataUri(issuer: URI): URI { return UriComponentsBuilder.fromUri(issuer) .replacePath(issuer.path + OAuthConstants.OIDC_METADATA_PATH) .build(Collections.emptyMap()) @@ -202,27 +210,58 @@ internal fun retrieveOidcConfiguration(uri: URI): Map { ) } +/** + * Result of validating endpoint URLs in the metadata against the configured issuer location. + * + * @param isValid `true` if all endpoint URLs in the metadata match the configured issuer location; `false` otherwise. + * @param mismatchedEndpoints A map of endpoint names to their actual URLs for endpoints that do not match the + * configured issuer location. + */ +data class MetadataValidationResult( + val isValid: Boolean, + val mismatchedEndpoints: Map +) + /** * As the issuer in metadata returned from Azure B2C provider is not the same as the configured issuer location, * we must instead validate that the endpoint URLs in the metadata start with the configured issuer location. * * @param configuration The OIDC configuration metadata. * @param uri The issuer location URI to validate against. - * @return `true` if all endpoint URLs in the metadata match the configured issuer location; `false` otherwise. + * @return A MetadataValidationResult containing whether all endpoint URLs match the configured issuer location, + * and a map of any mismatched endpoints with their actual values. */ -internal fun isValidAzureB2CMetadata( +internal fun validateAzureB2CMetadata( configuration: Map, uri: URI -): Boolean { +): MetadataValidationResult { val metadata = parse(configuration, OIDCProviderMetadata::parse) - val issuerASCIIString = uri.toASCIIString() - return listOf( - metadata.authorizationEndpointURI, - metadata.tokenEndpointURI, - metadata.endSessionEndpointURI, - metadata.jwkSetURI, - metadata.userInfoEndpointURI - ).all { it.toASCIIString().startsWith(issuerASCIIString) } + val unversionedIssuer = uri.toASCIIString().removeVersionSegment() + + val endpoints = mapOf( + "authorizationEndpointURI" to metadata.authorizationEndpointURI, + "tokenEndpointURI" to metadata.tokenEndpointURI, + "endSessionEndpointURI" to metadata.endSessionEndpointURI, + "jwkSetURI" to metadata.jwkSetURI, + "userInfoEndpointURI" to metadata.userInfoEndpointURI + ) + + val mismatchedEndpoints = endpoints.filterValues { + it.toASCIIString().startsWith(prefix = unversionedIssuer, ignoreCase = true).not() + }.mapValues { it.value.toASCIIString() } + + return MetadataValidationResult( + isValid = mismatchedEndpoints.isEmpty(), + mismatchedEndpoints = mismatchedEndpoints + ) +} + +/** + * Remove version segment from the issuer location URL + */ +internal fun String.removeVersionSegment(): String { + val regex = Regex("""/v\d+(\.\d+)*""") + return this.replace(regex, "") } /** diff --git a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/AuthenticationUtilsTest.kt b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/AuthenticationUtilsTest.kt index 62974a3..f19086b 100644 --- a/gooddata-server-oauth2-autoconfigure/src/test/kotlin/AuthenticationUtilsTest.kt +++ b/gooddata-server-oauth2-autoconfigure/src/test/kotlin/AuthenticationUtilsTest.kt @@ -16,7 +16,6 @@ package com.gooddata.oauth2.server -import com.fasterxml.jackson.databind.ObjectMapper import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock import com.github.tomakehurst.wiremock.core.WireMockConfiguration @@ -62,8 +61,6 @@ internal class AuthenticationUtilsTest { lateinit var clientRegistrationBuilderCache: ClientRegistrationBuilderCache - lateinit var objectMapper: ObjectMapper - @BeforeEach internal fun setUp() { properties = HostBasedClientRegistrationRepositoryProperties("http://remote", "http://localhost") @@ -321,17 +318,18 @@ internal class AuthenticationUtilsTest { } @Test - fun `isValidAzureB2CMetadata returns true for valid metadata`() { + fun `validateAzureB2CMetadata returns true for valid metadata`() { val uri = URI.create(AZURE_B2C_ISSUER) - - assertTrue(isValidAzureB2CMetadata(VALID_AZURE_B2C_OIDC_CONFIG, uri)) + assertTrue(validateAzureB2CMetadata(VALID_AZURE_B2C_OIDC_CONFIG, uri).isValid) } @Test - fun `isValidAzureB2CMetadata returns false for invalid metadata`() { + fun `validateAzureB2CMetadata returns false for invalid metadata`() { val uri = URI.create(AZURE_B2C_ISSUER) - - assertFalse(isValidAzureB2CMetadata(INVALID_AZURE_B2C_OIDC_CONFIG, uri)) + val validationResult = validateAzureB2CMetadata(INVALID_AZURE_B2C_OIDC_CONFIG, uri) + assertFalse(validationResult.isValid) + // The [INVALID_AZURE_B2C_OIDC_CONFIG] has 5 mismatched endpoints + assertEquals(5, validationResult.mismatchedEndpoints.size) } private fun mockOidcIssuer(): String { @@ -351,6 +349,7 @@ internal class AuthenticationUtilsTest { private const val OIDC_CONFIG_PATH = "/.well-known/openid-configuration" private const val USER_ID = "userId" private const val AZURE_B2C_ISSUER = "https://tenant.b2clogin.com/tenant.onmicrosoft.com/policy/v2.0" + private val UNVERSIONED_AZURE_B2C_ISSUER = AZURE_B2C_ISSUER.removeVersionSegment() private val ORGANIZATION = Organization(ORGANIZATION_ID) private val wireMockServer = WireMockServer(WireMockConfiguration().dynamicPort()).apply { start() @@ -512,11 +511,11 @@ internal class AuthenticationUtilsTest { private val VALID_AZURE_B2C_OIDC_CONFIG: Map = mapOf( "issuer" to "https://some-microsoft-issuer.com/someGuid/v2.0/", - "authorization_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/authorize", - "token_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/token", - "userinfo_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/userinfo", - "registration_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/clients", - "jwks_uri" to "${AZURE_B2C_ISSUER}/oauth2/v1/keys", + "authorization_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/authorize", + "token_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/token", + "userinfo_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/userinfo", + "registration_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/clients", + "jwks_uri" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/keys", "response_types_supported" to listOf( "code", "id_token", @@ -590,7 +589,7 @@ internal class AuthenticationUtilsTest { "c_hash" ), "code_challenge_methods_supported" to listOf("S256"), - "introspection_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/introspect", + "introspection_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/introspect", "introspection_endpoint_auth_methods_supported" to listOf( "client_secret_basic", "client_secret_post", @@ -598,7 +597,7 @@ internal class AuthenticationUtilsTest { "private_key_jwt", "none" ), - "revocation_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/revoke", + "revocation_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/revoke", "revocation_endpoint_auth_methods_supported" to listOf( "client_secret_basic", "client_secret_post", @@ -606,7 +605,7 @@ internal class AuthenticationUtilsTest { "private_key_jwt", "none" ), - "end_session_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/logout", + "end_session_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/logout", "request_parameter_supported" to true, "request_uri_parameter_supported" to true, "request_object_signing_alg_values_supported" to listOf( @@ -620,7 +619,7 @@ internal class AuthenticationUtilsTest { "ES384", "ES512" ), - "device_authorization_endpoint" to "${AZURE_B2C_ISSUER}/oauth2/v1/device/authorize" + "device_authorization_endpoint" to "${UNVERSIONED_AZURE_B2C_ISSUER}/oauth2/v1/device/authorize" ) private val INVALID_AZURE_B2C_OIDC_CONFIG: Map = mapOf(