Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: proper azure b2c endpoint validation #38

Merged
merged 1 commit into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>())
Expand All @@ -202,27 +210,58 @@ internal fun retrieveOidcConfiguration(uri: URI): Map<String, Any> {
)
}

/**
* 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<String, String>
)

/**
* 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<String, Any>,
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, "")
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -512,11 +511,11 @@ internal class AuthenticationUtilsTest {

private val VALID_AZURE_B2C_OIDC_CONFIG: Map<String, Any> = 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",
Expand Down Expand Up @@ -590,23 +589,23 @@ 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",
"client_secret_jwt",
"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",
"client_secret_jwt",
"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(
Expand All @@ -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<String, Any> = mapOf(
Expand Down
Loading