Skip to content

Commit d311c84

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 d311c84

File tree

3 files changed

+291
-23
lines changed

3 files changed

+291
-23
lines changed

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

Lines changed: 162 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.gooddata.oauth2.server
1717

1818
import com.gooddata.oauth2.server.OAuthConstants.GD_USER_GROUPS_SCOPE
19+
import com.gooddata.oauth2.server.oauth2.client.fromOidcConfiguration
1920
import com.nimbusds.jose.JWSAlgorithm
2021
import com.nimbusds.jose.jwk.JWKSet
2122
import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet
@@ -28,16 +29,20 @@ import com.nimbusds.jwt.proc.BadJWTException
2829
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
2930
import com.nimbusds.jwt.proc.DefaultJWTProcessor
3031
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
32+
import com.nimbusds.oauth2.sdk.ParseException
3133
import com.nimbusds.oauth2.sdk.Scope
3234
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
3335
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
3436
import java.security.MessageDigest
3537
import java.time.Instant
3638
import net.minidev.json.JSONObject
39+
import org.springframework.core.ParameterizedTypeReference
3740
import org.springframework.core.convert.ConversionService
3841
import org.springframework.core.convert.TypeDescriptor
3942
import org.springframework.core.convert.converter.Converter
4043
import org.springframework.http.HttpStatus
44+
import org.springframework.http.RequestEntity
45+
import org.springframework.http.client.SimpleClientHttpRequestFactory
4146
import org.springframework.security.core.Authentication
4247
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
4348
import org.springframework.security.oauth2.client.registration.ClientRegistration
@@ -50,8 +55,12 @@ import org.springframework.security.oauth2.jwt.MappedJwtClaimSetConverter
5055
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
5156
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException
5257
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
58+
import org.springframework.web.client.RestTemplate
5359
import org.springframework.web.server.ResponseStatusException
60+
import org.springframework.web.util.UriComponentsBuilder
5461
import reactor.core.publisher.Mono
62+
import java.net.URI
63+
import java.util.Collections
5564

5665
/**
5766
* Constants for OAuth type authentication which are not directly available in the Spring Security.
@@ -65,8 +74,24 @@ object OAuthConstants {
6574
*/
6675
const val REDIRECT_URL_BASE = "{baseUrl}/{action}/oauth2/code/"
6776
const val GD_USER_GROUPS_SCOPE = "urn.gooddata.scope/user_groups"
77+
const val OIDC_METADATA_PATH = "/.well-known/openid-configuration"
78+
const val CONNECTION_TIMEOUT = 30_000
79+
const val READ_TIMEOUT = 30_000
6880
}
6981

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

111241
/**
112242
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +393,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263393
.userInfoAuthenticationMethod(AuthenticationMethod.HEADER)
264394
.jwkSetUri("${properties.localAddress}/dex/keys")
265395

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

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,13 @@ 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+
return lowerCasedHost.endsWith("b2clogin.com") && path.contains("onmicrosoft.com")
52+
}
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)