13
13
* See the License for the specific language governing permissions and
14
14
* limitations under the License.
15
15
*/
16
+ @file:Suppress(" TooManyFunctions" )
16
17
package com.gooddata.oauth2.server
17
18
18
19
import com.gooddata.oauth2.server.OAuthConstants.GD_USER_GROUPS_SCOPE
20
+ import com.gooddata.oauth2.server.oauth2.client.fromOidcConfiguration
19
21
import com.nimbusds.jose.JWSAlgorithm
20
22
import com.nimbusds.jose.jwk.JWKSet
21
23
import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet
@@ -28,16 +30,20 @@ import com.nimbusds.jwt.proc.BadJWTException
28
30
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
29
31
import com.nimbusds.jwt.proc.DefaultJWTProcessor
30
32
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
33
+ import com.nimbusds.oauth2.sdk.ParseException
31
34
import com.nimbusds.oauth2.sdk.Scope
32
35
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
33
36
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
34
37
import java.security.MessageDigest
35
38
import java.time.Instant
36
39
import net.minidev.json.JSONObject
40
+ import org.springframework.core.ParameterizedTypeReference
37
41
import org.springframework.core.convert.ConversionService
38
42
import org.springframework.core.convert.TypeDescriptor
39
43
import org.springframework.core.convert.converter.Converter
40
44
import org.springframework.http.HttpStatus
45
+ import org.springframework.http.RequestEntity
46
+ import org.springframework.http.client.SimpleClientHttpRequestFactory
41
47
import org.springframework.security.core.Authentication
42
48
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
43
49
import org.springframework.security.oauth2.client.registration.ClientRegistration
@@ -50,8 +56,12 @@ import org.springframework.security.oauth2.jwt.MappedJwtClaimSetConverter
50
56
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
51
57
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException
52
58
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
59
+ import org.springframework.web.client.RestTemplate
53
60
import org.springframework.web.server.ResponseStatusException
61
+ import org.springframework.web.util.UriComponentsBuilder
54
62
import reactor.core.publisher.Mono
63
+ import java.net.URI
64
+ import java.util.Collections
55
65
56
66
/* *
57
67
* Constants for OAuth type authentication which are not directly available in the Spring Security.
@@ -65,48 +75,174 @@ object OAuthConstants {
65
75
*/
66
76
const val REDIRECT_URL_BASE = " {baseUrl}/{action}/oauth2/code/"
67
77
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
68
81
}
69
82
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
+
70
96
/* *
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.
72
102
*
73
103
* @param registrationId registration ID to be used
74
104
* @param organization organization object retrieved from [AuthenticationStoreClient]
75
105
* @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]
78
109
*/
79
110
@SuppressWarnings(" TooGenericExceptionCaught" )
80
111
fun buildClientRegistration (
81
112
registrationId : String ,
82
113
organization : Organization ,
83
114
properties : HostBasedClientRegistrationRepositoryProperties ,
84
115
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)
101
126
}
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)
105
168
} 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
+ }
110
246
111
247
/* *
112
248
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +399,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263
399
.userInfoAuthenticationMethod(AuthenticationMethod .HEADER )
264
400
.jwkSetUri(" ${properties.localAddress} /dex/keys" )
265
401
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
+
266
411
/* *
267
412
* Remove illegal characters from string according to OAuth2 specification
268
413
*/
0 commit comments