Skip to content

Commit 79414a6

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 79414a6

File tree

5 files changed

+618
-24
lines changed

5 files changed

+618
-24
lines changed

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

Lines changed: 163 additions & 23 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,8 +75,24 @@ 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
/**
7197
* Builds [ClientRegistration] from [Organization] retrieved from [AuthenticationStoreClient].
7298
*
@@ -82,31 +108,136 @@ fun buildClientRegistration(
82108
organization: Organization,
83109
properties: HostBasedClientRegistrationRepositoryProperties,
84110
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-
}
111+
): ClientRegistration {
112+
val issuerLocation = organization.oauthIssuerLocation
113+
?: return dexClientRegistration(registrationId, properties, organization)
114+
115+
return clientRegistrationBuilderCache.get(issuerLocation) {
116+
try {
117+
if (issuerLocation.toUri().isAzureB2C()) {
118+
handleAzureB2CClientRegistration(issuerLocation)
119+
} else {
120+
ClientRegistrations.fromIssuerLocation(issuerLocation)
101121
}
102-
}
103-
.registrationId(registrationId)
104-
.withRedirectUri(organization.oauthIssuerId)
122+
} catch (ex: RuntimeException) {
123+
handleRuntimeException(ex, issuerLocation)
124+
} as ClientRegistration.Builder
125+
}
126+
.registrationId(registrationId)
127+
.withRedirectUri(organization.oauthIssuerId)
128+
.buildWithIssuerConfig(organization)
129+
}
130+
131+
/**
132+
* Provides a DEX [ClientRegistration.Builder] for the given [registrationId] and [organization].
133+
*
134+
* @param registrationId Identifier for the client registration.
135+
* @param properties Properties for host-based client registration repository.
136+
* @param organization The organization for which to build the client registration.
137+
* @return A [ClientRegistration.Builder] configured with a default Dex configuration.
138+
*/
139+
private fun dexClientRegistration(
140+
registrationId: String,
141+
properties: HostBasedClientRegistrationRepositoryProperties,
142+
organization: Organization
143+
): ClientRegistration = ClientRegistration
144+
.withRegistrationId(registrationId)
145+
.withDexConfig(properties)
146+
.buildWithIssuerConfig(organization)
147+
148+
/**
149+
* Handles client registration for Azure B2C by validating issuer metadata and building the registration.
150+
*
151+
* @param issuerLocation The issuer location URL as a string.
152+
* @return A configured [ClientRegistration] instance for Azure B2C.
153+
* @throws ResponseStatusException if the metadata endpoints do not match the issuer location.
154+
*/
155+
private fun handleAzureB2CClientRegistration(
156+
issuerLocation: String
157+
): ClientRegistration.Builder {
158+
val uri = buildMetadataUri(issuerLocation)
159+
val configuration = retrieveOidcConfiguration(uri)
160+
161+
return if (isValidAzureB2CMetadata(configuration, uri)) {
162+
fromOidcConfiguration(configuration)
105163
} else {
106-
ClientRegistration
107-
.withRegistrationId(registrationId)
108-
.withDexConfig(properties)
109-
}.buildWithIssuerConfig(organization)
164+
throw ResponseStatusException(
165+
HttpStatus.UNAUTHORIZED,
166+
"Authorization failed for given issuer \"$issuerLocation\". Metadata endpoints do not match."
167+
)
168+
}
169+
}
170+
171+
/**
172+
* Builds metadata retrieval URI based on the provided [issuerLocation].
173+
*
174+
* @param issuerLocation The issuer location URL as a string.
175+
* @return The constructed [URI] for metadata retrieval.
176+
*/
177+
internal fun buildMetadataUri(issuerLocation: String): URI {
178+
val issuer = URI.create(issuerLocation)
179+
return UriComponentsBuilder.fromUri(issuer)
180+
.replacePath(issuer.path + OAuthConstants.OIDC_METADATA_PATH)
181+
.build(Collections.emptyMap<String, String>())
182+
}
183+
184+
/**
185+
* Retrieves the OpenID Connect configuration from the specified metadata [uri].
186+
*
187+
* @param uri The URI from which to retrieve the configuration metadata
188+
* @return The OIDC configuration as a [Map] of [String] to [Any].
189+
* @throws ResponseStatusException if the configuration metadata cannot be retrieved.
190+
*/
191+
internal fun retrieveOidcConfiguration(uri: URI): Map<String, Any> {
192+
val request: RequestEntity<Void> = RequestEntity.get(uri).build()
193+
return rest.exchange(request, typeReference).body
194+
?: throw ResponseStatusException(
195+
HttpStatus.UNAUTHORIZED,
196+
"Authorization failed: unable to retrieve configuration metadata from \"$uri\"."
197+
)
198+
}
199+
200+
/**
201+
* As the issuer in metadata returned from Azure B2C provider is not the same as the configured issuer location,
202+
* we must instead validate that the endpoint URLs in the metadata start with the configured issuer location.
203+
*
204+
* @param configuration The OIDC configuration metadata.
205+
* @param uri The issuer location URI to validate against.
206+
* @return `true` if all endpoint URLs in the metadata match the configured issuer location; `false` otherwise.
207+
*/
208+
internal fun isValidAzureB2CMetadata(
209+
configuration: Map<String, Any>,
210+
uri: URI
211+
): Boolean {
212+
val metadata = parse(configuration, OIDCProviderMetadata::parse)
213+
val issuerASCIIString = uri.toASCIIString()
214+
return listOf(
215+
metadata.authorizationEndpointURI,
216+
metadata.tokenEndpointURI,
217+
metadata.endSessionEndpointURI,
218+
metadata.jwkSetURI,
219+
metadata.userInfoEndpointURI
220+
).all { it.toASCIIString().startsWith(issuerASCIIString) }
221+
}
222+
223+
/**
224+
* Handles [RuntimeException]s that may occur during client registration building
225+
*
226+
* @param ex The exception that was thrown.
227+
* @param issuerLocation The issuer location URL as a string, used for error messaging.
228+
* @throws ResponseStatusException with `UNAUTHORIZED` status for known exception types.
229+
* @throws RuntimeException for any other exceptions.
230+
*/
231+
private fun handleRuntimeException(ex: RuntimeException, issuerLocation: String) {
232+
when (ex) {
233+
is IllegalArgumentException,
234+
is IllegalStateException -> throw ResponseStatusException(
235+
HttpStatus.UNAUTHORIZED,
236+
"Authorization failed for given issuer \"$issuerLocation\". ${ex.message}"
237+
)
238+
else -> throw ex
239+
}
240+
}
110241

111242
/**
112243
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +394,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263394
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
264395
.jwkSetUri("${properties.localAddress}/dex/keys")
265396

397+
@Suppress("TooGenericExceptionThrown")
398+
fun <T> parse(body: Map<String, Any>, parser: (JSONObject) -> T): T {
399+
return try {
400+
parser(JSONObject(body))
401+
} catch (ex: ParseException) {
402+
throw RuntimeException(ex)
403+
}
404+
}
405+
266406
/**
267407
* Remove illegal characters from string according to OAuth2 specification
268408
*/

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+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
* Copyright 2024 GoodData Corporation
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
* Forked from https://github.com/spring-projects/spring-security/blob/6.4.0-RC1/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/registration/ClientRegistrations.java
18+
*
19+
* The fromOidcConfiguration factory method (and necessary helper functions) has been extracted from the original class
20+
* for immediate use until the Spring Security stable version containing it is released.
21+
*
22+
* This class and its methods are subject to removal once the Spring Security stable version containing the
23+
* fromOidcConfiguration factory method is released and the dependency in the project is updated.
24+
*/
25+
package com.gooddata.oauth2.server.oauth2.client
26+
27+
import com.gooddata.oauth2.server.parse
28+
import com.nimbusds.oauth2.sdk.`as`.AuthorizationServerMetadata
29+
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
30+
import org.springframework.security.oauth2.client.registration.ClientRegistration
31+
import org.springframework.security.oauth2.core.AuthorizationGrantType
32+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod
33+
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames
34+
import org.springframework.util.Assert
35+
import java.net.URI
36+
37+
/**
38+
* Creates a {@link ClientRegistration.Builder} using the provided map representation
39+
* of an <a href=
40+
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
41+
* Provider Configuration Response</a> to initialize the
42+
* {@link ClientRegistration.Builder}.
43+
*
44+
* <p>
45+
* This is useful when the OpenID Provider Configuration is not available at a
46+
* well-known location, or if custom validation is needed for the issuer location
47+
* (e.g. if the issuer is only accessible from a back-channel URI that is different
48+
* from the issuer value in the configuration).
49+
* </p>
50+
*
51+
* <p>
52+
* Example usage:
53+
* </p>
54+
* <pre>
55+
* RequestEntity&lt;Void&gt; request = RequestEntity.get(metadataEndpoint).build();
56+
* ParameterizedTypeReference&lt;Map&lt;String, Object&gt;&gt; typeReference =
57+
* new ParameterizedTypeReference&lt;&gt;() {};
58+
* Map&lt;String, Object&gt; configuration = rest.exchange(request, typeReference).getBody();
59+
* // Validate configuration.get("issuer") as per in the OIDC specification
60+
* ClientRegistration registration = ClientRegistrations.fromOidcConfiguration(configuration)
61+
* .clientId("client-id")
62+
* .clientSecret("client-secret")
63+
* .build();
64+
* </pre>
65+
* @param the OpenID Provider configuration map
66+
* @return the {@link ClientRegistration} built from the configuration
67+
*/
68+
fun fromOidcConfiguration(configuration: Map<String, Any>): ClientRegistration.Builder {
69+
val metadata: OIDCProviderMetadata = parse(configuration, OIDCProviderMetadata::parse)
70+
val builder: ClientRegistration.Builder = withProviderConfiguration(metadata, metadata.issuer.value)
71+
builder.jwkSetUri(metadata.jwkSetURI.toASCIIString())
72+
if (metadata.userInfoEndpointURI != null) {
73+
builder.userInfoUri(metadata.userInfoEndpointURI.toASCIIString())
74+
}
75+
return builder
76+
}
77+
78+
private fun withProviderConfiguration(
79+
metadata: AuthorizationServerMetadata,
80+
issuer: String
81+
): ClientRegistration.Builder {
82+
val metadataIssuer: String = metadata.issuer.value
83+
Assert.state(issuer == metadataIssuer) {
84+
"The Issuer \"$metadataIssuer\" provided in the configuration metadata did " +
85+
"not match the requested issuer \"$issuer\""
86+
}
87+
val name: String = URI.create(issuer).host
88+
val method: ClientAuthenticationMethod? = getClientAuthenticationMethod(metadata.tokenEndpointAuthMethods)
89+
val configurationMetadata: Map<String, Any> = LinkedHashMap(metadata.toJSONObject())
90+
91+
return ClientRegistration.withRegistrationId(name)
92+
.userNameAttributeName(IdTokenClaimNames.SUB)
93+
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
94+
.clientAuthenticationMethod(method)
95+
.redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
96+
.authorizationUri(metadata.authorizationEndpointURI?.toASCIIString())
97+
.providerConfigurationMetadata(configurationMetadata)
98+
.tokenUri(metadata.tokenEndpointURI.toASCIIString())
99+
.issuerUri(issuer)
100+
.clientName(issuer)
101+
}
102+
103+
private fun getClientAuthenticationMethod(
104+
metadataAuthMethods: List<com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod>?
105+
): ClientAuthenticationMethod? {
106+
if (metadataAuthMethods == null || metadataAuthMethods
107+
.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
108+
) {
109+
// If null, the default includes client_secret_basic
110+
return ClientAuthenticationMethod.CLIENT_SECRET_BASIC
111+
}
112+
if (metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
113+
ClientAuthenticationMethod.CLIENT_SECRET_POST
114+
}
115+
if (metadataAuthMethods.contains(com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod.NONE)) {
116+
ClientAuthenticationMethod.NONE
117+
}
118+
return null
119+
}

0 commit comments

Comments
 (0)