Skip to content

Commit e9eb1d7

Browse files
feat: support for Azure B2C Provider
Updated to handle Azure B2C separately when building ClientRegistration. This is necessary because the issuer returned by Azure B2C openid-configuration does not match the requested issuer, causing a mismatch error to be thrown. A new factory method was introduced (spring-projects/spring-security#15716) for similar issue and will be available in Spring Security 6.4.0. For now we have borrowed the implementation and necessary helpers into our own code and will upgrade the dependency once the stable version is released and we've been able to properly test it. JIRA: LX-614 risk: high
1 parent 009e4a4 commit e9eb1d7

File tree

5 files changed

+626
-27
lines changed

5 files changed

+626
-27
lines changed

gooddata-server-oauth2-autoconfigure/src/main/kotlin/AuthenticationUtils.kt

Lines changed: 171 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
@file:Suppress("TooManyFunctions")
1617
package com.gooddata.oauth2.server
1718

1819
import com.gooddata.oauth2.server.OAuthConstants.GD_USER_GROUPS_SCOPE
20+
import com.gooddata.oauth2.server.oauth2.client.fromOidcConfiguration
1921
import com.nimbusds.jose.JWSAlgorithm
2022
import com.nimbusds.jose.jwk.JWKSet
2123
import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet
@@ -28,16 +30,20 @@ import com.nimbusds.jwt.proc.BadJWTException
2830
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
2931
import com.nimbusds.jwt.proc.DefaultJWTProcessor
3032
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
33+
import com.nimbusds.oauth2.sdk.ParseException
3134
import com.nimbusds.oauth2.sdk.Scope
3235
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
3336
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
3437
import java.security.MessageDigest
3538
import java.time.Instant
3639
import net.minidev.json.JSONObject
40+
import org.springframework.core.ParameterizedTypeReference
3741
import org.springframework.core.convert.ConversionService
3842
import org.springframework.core.convert.TypeDescriptor
3943
import org.springframework.core.convert.converter.Converter
4044
import org.springframework.http.HttpStatus
45+
import org.springframework.http.RequestEntity
46+
import org.springframework.http.client.SimpleClientHttpRequestFactory
4147
import org.springframework.security.core.Authentication
4248
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
4349
import org.springframework.security.oauth2.client.registration.ClientRegistration
@@ -50,8 +56,12 @@ import org.springframework.security.oauth2.jwt.MappedJwtClaimSetConverter
5056
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
5157
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException
5258
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
59+
import org.springframework.web.client.RestTemplate
5360
import org.springframework.web.server.ResponseStatusException
61+
import org.springframework.web.util.UriComponentsBuilder
5462
import reactor.core.publisher.Mono
63+
import java.net.URI
64+
import java.util.Collections
5565

5666
/**
5767
* Constants for OAuth type authentication which are not directly available in the Spring Security.
@@ -65,48 +75,174 @@ object OAuthConstants {
6575
*/
6676
const val REDIRECT_URL_BASE = "{baseUrl}/{action}/oauth2/code/"
6777
const val GD_USER_GROUPS_SCOPE = "urn.gooddata.scope/user_groups"
78+
const val OIDC_METADATA_PATH = "/.well-known/openid-configuration"
79+
const val CONNECTION_TIMEOUT = 30_000
80+
const val READ_TIMEOUT = 30_000
6881
}
6982

83+
private val rest: RestTemplate by lazy {
84+
val requestFactory = SimpleClientHttpRequestFactory().apply {
85+
setConnectTimeout(OAuthConstants.CONNECTION_TIMEOUT)
86+
setReadTimeout(OAuthConstants.READ_TIMEOUT)
87+
}
88+
RestTemplate().apply {
89+
this.requestFactory = requestFactory
90+
}
91+
}
92+
93+
private val typeReference: ParameterizedTypeReference<Map<String, Any>> = object :
94+
ParameterizedTypeReference<Map<String, Any>>() {}
95+
7096
/**
71-
* Builds [ClientRegistration] from [Organization] retrieved from [AuthenticationStoreClient].
97+
* Builds a client registration based on [Organization] details.
98+
*
99+
* In the case that the issuer location is an Azure B2C provider, the metadata is retrieved via a separate handler
100+
* that performs validation on the endpoints instead of the issuer since Azure B2C openid-configuration does not
101+
* return a matching issuer value.
72102
*
73103
* @param registrationId registration ID to be used
74104
* @param organization organization object retrieved from [AuthenticationStoreClient]
75105
* @param properties static properties for being able to configure pre-configured DEX issuer
76-
* @param clientRegistrationBuilderCache the cache where non-DEX client registration builders are saved
77-
* for improving performance
106+
* @param clientRegistrationBuilderCache the cache where non-DEX client registration builders are saved for improving
107+
* performance
108+
* @return A [ClientRegistration]
78109
*/
79110
@SuppressWarnings("TooGenericExceptionCaught")
80111
fun buildClientRegistration(
81112
registrationId: String,
82113
organization: Organization,
83114
properties: HostBasedClientRegistrationRepositoryProperties,
84115
clientRegistrationBuilderCache: ClientRegistrationBuilderCache,
85-
): ClientRegistration =
86-
if (organization.oauthIssuerLocation != null) {
87-
clientRegistrationBuilderCache.get(organization.oauthIssuerLocation) {
88-
try {
89-
ClientRegistrations.fromIssuerLocation(organization.oauthIssuerLocation)
90-
} catch (ex: RuntimeException) {
91-
when (ex) {
92-
is IllegalArgumentException,
93-
is IllegalStateException,
94-
-> throw ResponseStatusException(
95-
HttpStatus.UNAUTHORIZED,
96-
"Authorization failed for given issuer \"${organization.oauthIssuerLocation}\". ${ex.message}"
97-
)
98-
99-
else -> throw ex
100-
}
116+
): ClientRegistration {
117+
val issuerLocation = organization.oauthIssuerLocation
118+
?: return dexClientRegistration(registrationId, properties, organization)
119+
120+
return clientRegistrationBuilderCache.get(issuerLocation) {
121+
try {
122+
if (issuerLocation.toUri().isAzureB2C()) {
123+
handleAzureB2CClientRegistration(issuerLocation)
124+
} else {
125+
ClientRegistrations.fromIssuerLocation(issuerLocation)
101126
}
102-
}
103-
.registrationId(registrationId)
104-
.withRedirectUri(organization.oauthIssuerId)
127+
} catch (ex: RuntimeException) {
128+
handleRuntimeException(ex, issuerLocation)
129+
} as ClientRegistration.Builder
130+
}
131+
.registrationId(registrationId)
132+
.withRedirectUri(organization.oauthIssuerId)
133+
.buildWithIssuerConfig(organization)
134+
}
135+
136+
/**
137+
* Provides a DEX [ClientRegistration] for the given [registrationId] and [organization].
138+
*
139+
* @param registrationId Identifier for the client registration.
140+
* @param properties Properties for host-based client registration repository.
141+
* @param organization The organization for which to build the client registration.
142+
* @return A [ClientRegistration] configured with a default Dex configuration.
143+
*/
144+
private fun dexClientRegistration(
145+
registrationId: String,
146+
properties: HostBasedClientRegistrationRepositoryProperties,
147+
organization: Organization
148+
): ClientRegistration = ClientRegistration
149+
.withRegistrationId(registrationId)
150+
.withDexConfig(properties)
151+
.buildWithIssuerConfig(organization)
152+
153+
/**
154+
* Handles client registration for Azure B2C by validating issuer metadata and building the registration.
155+
*
156+
* @param issuerLocation The issuer location URL as a string.
157+
* @return A configured [ClientRegistration] instance for Azure B2C.
158+
* @throws ResponseStatusException if the metadata endpoints do not match the issuer location.
159+
*/
160+
private fun handleAzureB2CClientRegistration(
161+
issuerLocation: String
162+
): ClientRegistration.Builder {
163+
val uri = buildMetadataUri(issuerLocation)
164+
val configuration = retrieveOidcConfiguration(uri)
165+
166+
return if (isValidAzureB2CMetadata(configuration, uri)) {
167+
fromOidcConfiguration(configuration)
105168
} else {
106-
ClientRegistration
107-
.withRegistrationId(registrationId)
108-
.withDexConfig(properties)
109-
}.buildWithIssuerConfig(organization)
169+
throw ResponseStatusException(
170+
HttpStatus.UNAUTHORIZED,
171+
"Authorization failed for given issuer \"$issuerLocation\". Metadata endpoints do not match."
172+
)
173+
}
174+
}
175+
176+
/**
177+
* Builds metadata retrieval URI based on the provided [issuerLocation].
178+
*
179+
* @param issuerLocation The issuer location URL as a string.
180+
* @return The constructed [URI] for metadata retrieval.
181+
*/
182+
internal fun buildMetadataUri(issuerLocation: String): URI {
183+
val issuer = URI.create(issuerLocation)
184+
return UriComponentsBuilder.fromUri(issuer)
185+
.replacePath(issuer.path + OAuthConstants.OIDC_METADATA_PATH)
186+
.build(Collections.emptyMap<String, String>())
187+
}
188+
189+
/**
190+
* Retrieves the OpenID Connect configuration from the specified metadata [uri].
191+
*
192+
* @param uri The URI from which to retrieve the configuration metadata
193+
* @return The OIDC configuration as a [Map] of [String] to [Any].
194+
* @throws ResponseStatusException if the configuration metadata cannot be retrieved.
195+
*/
196+
internal fun retrieveOidcConfiguration(uri: URI): Map<String, Any> {
197+
val request: RequestEntity<Void> = RequestEntity.get(uri).build()
198+
return rest.exchange(request, typeReference).body
199+
?: throw ResponseStatusException(
200+
HttpStatus.UNAUTHORIZED,
201+
"Authorization failed: unable to retrieve configuration metadata from \"$uri\"."
202+
)
203+
}
204+
205+
/**
206+
* As the issuer in metadata returned from Azure B2C provider is not the same as the configured issuer location,
207+
* we must instead validate that the endpoint URLs in the metadata start with the configured issuer location.
208+
*
209+
* @param configuration The OIDC configuration metadata.
210+
* @param uri The issuer location URI to validate against.
211+
* @return `true` if all endpoint URLs in the metadata match the configured issuer location; `false` otherwise.
212+
*/
213+
internal fun isValidAzureB2CMetadata(
214+
configuration: Map<String, Any>,
215+
uri: URI
216+
): Boolean {
217+
val metadata = parse(configuration, OIDCProviderMetadata::parse)
218+
val issuerASCIIString = uri.toASCIIString()
219+
return listOf(
220+
metadata.authorizationEndpointURI,
221+
metadata.tokenEndpointURI,
222+
metadata.endSessionEndpointURI,
223+
metadata.jwkSetURI,
224+
metadata.userInfoEndpointURI
225+
).all { it.toASCIIString().startsWith(issuerASCIIString) }
226+
}
227+
228+
/**
229+
* Handles [RuntimeException]s that may occur during client registration building
230+
*
231+
* @param ex The exception that was thrown.
232+
* @param issuerLocation The issuer location URL as a string, used for error messaging.
233+
* @throws ResponseStatusException with `UNAUTHORIZED` status for known exception types.
234+
* @throws RuntimeException for any other exceptions.
235+
*/
236+
private fun handleRuntimeException(ex: RuntimeException, issuerLocation: String) {
237+
when (ex) {
238+
is IllegalArgumentException,
239+
is IllegalStateException -> throw ResponseStatusException(
240+
HttpStatus.UNAUTHORIZED,
241+
"Authorization failed for given issuer \"$issuerLocation\". ${ex.message}"
242+
)
243+
else -> throw ex
244+
}
245+
}
110246

111247
/**
112248
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +399,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263399
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
264400
.jwkSetUri("${properties.localAddress}/dex/keys")
265401

402+
@Suppress("TooGenericExceptionThrown")
403+
fun <T> parse(body: Map<String, Any>, parser: (JSONObject) -> T): T {
404+
return try {
405+
parser(JSONObject(body))
406+
} catch (ex: ParseException) {
407+
throw RuntimeException(ex)
408+
}
409+
}
410+
266411
/**
267412
* Remove illegal characters from string according to OAuth2 specification
268413
*/

gooddata-server-oauth2-autoconfigure/src/main/kotlin/UriExtensions.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,18 @@ fun URI.isCognito(): Boolean {
4040
val lowerCasedHost = host?.lowercase() ?: return false
4141
return lowerCasedHost.endsWith("amazonaws.com") && lowerCasedHost.startsWith("cognito-idp")
4242
}
43+
44+
/**
45+
* Check if URI is Azure B2C issuer
46+
*/
47+
@Suppress("ReturnCount")
48+
fun URI.isAzureB2C(): Boolean {
49+
val lowerCasedHost = host?.lowercase() ?: return false
50+
val path = path?.lowercase() ?: return false
51+
52+
val azureB2CPattern = Regex(
53+
pattern = "^https://([a-zA-Z0-9-]+)\\.b2clogin\\.com/\\1\\.onmicrosoft\\.com/[a-zA-Z0-9-_]+(/v2\\.0)?/?$"
54+
)
55+
56+
return azureB2CPattern.matches("$scheme://$lowerCasedHost$path")
57+
}

0 commit comments

Comments
 (0)