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,8 +75,24 @@ 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
97
* Builds [ClientRegistration] from [Organization] retrieved from [AuthenticationStoreClient].
72
98
*
@@ -82,31 +108,136 @@ fun buildClientRegistration(
82
108
organization : Organization ,
83
109
properties : HostBasedClientRegistrationRepositoryProperties ,
84
110
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)
101
121
}
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)
105
163
} 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
+ }
110
241
111
242
/* *
112
243
* Prepares [NimbusReactiveJwtDecoder] that decodes incoming JWTs and validates these against JWKs from [jwkSet] and
@@ -263,6 +394,15 @@ private fun ClientRegistration.Builder.withDexConfig(
263
394
.userInfoAuthenticationMethod(AuthenticationMethod .HEADER )
264
395
.jwkSetUri(" ${properties.localAddress} /dex/keys" )
265
396
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
+
266
406
/* *
267
407
* Remove illegal characters from string according to OAuth2 specification
268
408
*/
0 commit comments