16
16
package com.gooddata.oauth2.server
17
17
18
18
import com.gooddata.oauth2.server.OAuthConstants.GD_USER_GROUPS_SCOPE
19
+ import com.gooddata.oauth2.server.oauth2.client.fromOidcConfiguration
19
20
import com.nimbusds.jose.JWSAlgorithm
20
21
import com.nimbusds.jose.jwk.JWKSet
21
22
import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet
@@ -28,16 +29,20 @@ import com.nimbusds.jwt.proc.BadJWTException
28
29
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
29
30
import com.nimbusds.jwt.proc.DefaultJWTProcessor
30
31
import com.nimbusds.jwt.proc.JWTClaimsSetVerifier
32
+ import com.nimbusds.oauth2.sdk.ParseException
31
33
import com.nimbusds.oauth2.sdk.Scope
32
34
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
33
35
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
34
36
import java.security.MessageDigest
35
37
import java.time.Instant
36
38
import net.minidev.json.JSONObject
39
+ import org.springframework.core.ParameterizedTypeReference
37
40
import org.springframework.core.convert.ConversionService
38
41
import org.springframework.core.convert.TypeDescriptor
39
42
import org.springframework.core.convert.converter.Converter
40
43
import org.springframework.http.HttpStatus
44
+ import org.springframework.http.RequestEntity
45
+ import org.springframework.http.client.SimpleClientHttpRequestFactory
41
46
import org.springframework.security.core.Authentication
42
47
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
43
48
import org.springframework.security.oauth2.client.registration.ClientRegistration
@@ -50,8 +55,12 @@ import org.springframework.security.oauth2.jwt.MappedJwtClaimSetConverter
50
55
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
51
56
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException
52
57
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
58
+ import org.springframework.web.client.RestTemplate
53
59
import org.springframework.web.server.ResponseStatusException
60
+ import org.springframework.web.util.UriComponentsBuilder
54
61
import reactor.core.publisher.Mono
62
+ import java.net.URI
63
+ import java.util.Collections
55
64
56
65
/* *
57
66
* Constants for OAuth type authentication which are not directly available in the Spring Security.
@@ -65,8 +74,24 @@ object OAuthConstants {
65
74
*/
66
75
const val REDIRECT_URL_BASE = " {baseUrl}/{action}/oauth2/code/"
67
76
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
68
80
}
69
81
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
+
70
95
/* *
71
96
* Builds [ClientRegistration] from [Organization] retrieved from [AuthenticationStoreClient].
72
97
*
@@ -82,31 +107,136 @@ fun buildClientRegistration(
82
107
organization : Organization ,
83
108
properties : HostBasedClientRegistrationRepositoryProperties ,
84
109
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)
101
120
}
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)
105
162
} 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
+ }
110
240
111
241
/* *
112
242
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +393,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263
393
.userInfoAuthenticationMethod(AuthenticationMethod .HEADER )
264
394
.jwkSetUri(" ${properties.localAddress} /dex/keys" )
265
395
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
+
266
405
/* *
267
406
* Remove illegal characters from string according to OAuth2 specification
268
407
*/
0 commit comments