diff --git a/README.md b/README.md index aa40a0c8..5d215036 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,14 @@ Variable: `ISSUER_DPOP_REALM` Description: Realm to report in the WWW-Authenticate header in case of DPoP authentication/authorization failure Default value: `pid-issuer` +Variable: `ISSUER_DPOP_NONCE_ENABLED` +Description: Whether Nonce values are required for DPoP authentication +Default value: `false` + +Variable: `ISSUER_DPOP_NONCE_EXPIRATION` +Description: Duration after which Nonce values for DPoP authentication expire +Default value: `PT5M` + Variable: `ISSUER_CREDENTIALENDPOINT_BATCHISSUANCE_ENABLED` Description: Whether to enable batch issuance support in the credential endpoint Default value: `true` diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt index 0f358c22..1838d804 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt @@ -86,6 +86,8 @@ import org.springframework.http.MediaType import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.http.codec.json.KotlinSerializationJsonDecoder import org.springframework.http.codec.json.KotlinSerializationJsonEncoder +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled import org.springframework.security.config.web.server.SecurityWebFiltersOrder import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.config.web.server.invoke @@ -110,6 +112,7 @@ import java.security.cert.X509Certificate import java.time.Clock import java.time.Duration import java.util.* +import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.days import kotlin.time.toJavaDuration import kotlin.time.toKotlinDuration @@ -479,6 +482,26 @@ fun beans(clock: Clock) = beans { metaDataApi.route.and(walletApi.route).and(issuerUi.router).and(issuerApi.router) } + // + // DPoP Nonce + // + if (env.getProperty("issuer.dpop.nonce.enabled", Boolean::class.java, false)) { + with(InMemoryDPoPNonceRepository(clock, Duration.parse(env.getProperty("issuer.dpop.nonce.expiration", "PT5M")))) { + bean { DPoPNoncePolicy.Enforcing(loadActiveDPoPNonce, generateDPoPNonce) } + bean { + object { + @Scheduled(fixedRate = 1L, timeUnit = TimeUnit.MINUTES) + suspend fun cleanup() { + cleanupInactiveDPoPNonce() + } + } + } + } + bean(::DPoPNonceWebFilter) + } else { + bean { DPoPNoncePolicy.Disabled } + } + // // Security // @@ -553,7 +576,7 @@ fun beans(clock: Clock) = beans { val enableDPoP = dPoPProperties.algorithms.isNotEmpty() val dPoPTokenConverter by lazy { ServerDPoPAuthenticationTokenAuthenticationConverter() } - val dPoPEntryPoint by lazy { DPoPTokenServerAuthenticationEntryPoint(dPoPProperties.realm) } + val dPoPEntryPoint by lazy { DPoPTokenServerAuthenticationEntryPoint(dPoPProperties.realm, ref()) } val bearerTokenConverter = ServerBearerTokenAuthenticationConverter() val bearerTokenEntryPoint = BearerTokenServerAuthenticationEntryPoint() @@ -627,13 +650,11 @@ fun beans(clock: Clock) = beans { ), ) - val authenticationManager = DPoPTokenReactiveAuthenticationManager(introspector, dPoPVerifier) + val authenticationManager = DPoPTokenReactiveAuthenticationManager(introspector, dPoPVerifier, ref()) AuthenticationWebFilter(authenticationManager).apply { setServerAuthenticationConverter(ServerDPoPAuthenticationTokenAuthenticationConverter()) - setAuthenticationFailureHandler( - ServerAuthenticationEntryPointFailureHandler(HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)), - ) + setAuthenticationFailureHandler(ServerAuthenticationEntryPointFailureHandler(dPoPEntryPoint)) } } @@ -836,6 +857,7 @@ fun BeanDefinitionDsl.initializer(): ApplicationContextInitializer { initialize(it) } @SpringBootApplication +@EnableScheduling class PidIssuerApplication fun main(args: Array) { diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonce.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonce.kt new file mode 100644 index 00000000..f5278465 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonce.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.input.web.security + +import com.nimbusds.oauth2.sdk.token.DPoPAccessToken +import com.nimbusds.openid.connect.sdk.Nonce +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.slf4j.LoggerFactory +import java.time.Clock +import java.time.Duration +import java.time.Instant + +/** + * A Nonce value used for DPoP authentication. + */ +data class DPoPNonce(val nonce: Nonce, val accessToken: DPoPAccessToken, val createdAt: Instant, val expiresAt: Instant) + +/** + * Loads the active Nonce value for DPoP, for a specific DPoP Access Token. + */ +fun interface LoadActiveDPoPNonce { + suspend operator fun invoke(accessToken: DPoPAccessToken): DPoPNonce? +} + +/** + * Generates a new Nonce value for DPoP, for a specific DPoP Access Token. + */ +fun interface GenerateDPoPNonce { + suspend operator fun invoke(accessToken: DPoPAccessToken): DPoPNonce +} + +/** + * Cleans up any inactive DPoP Nonce values. + */ +fun interface CleanupInactiveDPoPNonce { + suspend operator fun invoke() +} + +/** + * In memory repository providing implementations for [LoadActiveDPoPNonce], and [GenerateDPoPNonce]. + */ +class InMemoryDPoPNonceRepository( + private val clock: Clock, + private val dpopNonceExpiresIn: Duration = Duration.ofMinutes(5L), +) { + init { + require(!dpopNonceExpiresIn.isZero && !dpopNonceExpiresIn.isNegative) { "dpopNonceExpiresIn must be positive" } + } + + private val data = mutableMapOf() + private val mutex = Mutex() + private val log = LoggerFactory.getLogger(InMemoryDPoPNonceRepository::class.java) + + val loadActiveDPoPNonce: LoadActiveDPoPNonce by lazy { + LoadActiveDPoPNonce { accessToken -> + mutex.withLock { + data[accessToken]?.takeIf { dpopNonce -> dpopNonce.expiresAt > clock.instant() } + } + } + } + + val generateDPoPNonce: GenerateDPoPNonce by lazy { + GenerateDPoPNonce { accessToken -> + mutex.withLock { + val createdAt = clock.instant() + val expiresAt = createdAt + dpopNonceExpiresIn + val dpopNonce = DPoPNonce( + nonce = Nonce(), + accessToken = accessToken, + createdAt = createdAt, + expiresAt = expiresAt, + ) + data[accessToken] = dpopNonce + dpopNonce + } + } + } + + val cleanupInactiveDPoPNonce: CleanupInactiveDPoPNonce by lazy { + CleanupInactiveDPoPNonce { + mutex.withLock { + val now = clock.instant() + val inactive = data.entries.filter { (_, dpopNonce) -> dpopNonce.expiresAt >= now }.map { it.key } + inactive.forEach(data::remove) + + log.debug("Removed '${inactive.size}' inactive DPoPNonce values") + } + } + } +} + +/** + * Policy for DPoP Nonce. + */ +sealed interface DPoPNoncePolicy { + + /** + * Gets the [DPoPNonce] associated with [accessToken]. + * In case no [DPoPNonce] is associated with [accessToken] a new one might be generated. + */ + suspend fun getActiveOrGenerateNew(accessToken: DPoPAccessToken): DPoPNonce? + + /** + * [DPoPNonce] is enforced. + */ + class Enforcing( + val loadActiveDPoPNonce: LoadActiveDPoPNonce, + val generateDPoPNonce: GenerateDPoPNonce, + ) : DPoPNoncePolicy { + override suspend fun getActiveOrGenerateNew(accessToken: DPoPAccessToken): DPoPNonce = + loadActiveDPoPNonce(accessToken) ?: generateDPoPNonce(accessToken) + } + + /** + * [DPoPNonce] is disabled. + */ + data object Disabled : DPoPNoncePolicy { + override suspend fun getActiveOrGenerateNew(accessToken: DPoPAccessToken): DPoPNonce? = null + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonceWebFilter.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonceWebFilter.kt new file mode 100644 index 00000000..8ff53e9a --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPNonceWebFilter.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.input.web.security + +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.reactor.mono +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +/** + * [WebFilter] that checks if new DPoP Nonce values must be generated for DPoP authenticated web requests. + */ +class DPoPNonceWebFilter( + private val dpopNonce: DPoPNoncePolicy.Enforcing, +) : WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono = + mono { + val request = exchange.request + if (request.headers.contains("DPoP")) { + val authentication = ReactiveSecurityContextHolder.getContext() + .awaitSingleOrNull() + ?.authentication + + if (authentication is DPoPTokenAuthentication) { + val currentDPoPNonce = dpopNonce.loadActiveDPoPNonce(authentication.accessToken) + if (currentDPoPNonce == null) { + val newDPoPNonce = dpopNonce.generateDPoPNonce(authentication.accessToken) + val response = exchange.response + response.headers["DPoP-Nonce"] = newDPoPNonce.nonce.value + } + } + } + + chain.filter(exchange).awaitSingleOrNull() + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenError.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenError.kt index ae84552a..bbf955ba 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenError.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenError.kt @@ -15,6 +15,7 @@ */ package eu.europa.ec.eudi.pidissuer.adapter.input.web.security +import com.nimbusds.oauth2.sdk.token.DPoPAccessToken import org.springframework.http.HttpStatus import org.springframework.security.oauth2.core.OAuth2Error import org.springframework.security.oauth2.core.OAuth2ErrorCodes @@ -22,30 +23,58 @@ import org.springframework.security.oauth2.core.OAuth2ErrorCodes /** * Error returned in case of DPoP authentication failures. */ -class DPoPTokenError private constructor( +sealed class DPoPTokenError( errorCode: String, description: String, val status: HttpStatus, ) : OAuth2Error(errorCode, description, null) { + /** + * Indicates an invalid request. + */ + class InvalidRequest(description: String) : DPoPTokenError(OAuth2ErrorCodes.INVALID_REQUEST, description, HttpStatus.BAD_REQUEST) + + /** + * Indicates an invalid access token. + */ + class InvalidToken(description: String) : DPoPTokenError(OAuth2ErrorCodes.INVALID_TOKEN, description, HttpStatus.UNAUTHORIZED) + + /** + * Indicates an internal server error. + */ + class ServerError( + description: String, + val error: Throwable, + ) : DPoPTokenError(OAuth2ErrorCodes.SERVER_ERROR, description, HttpStatus.INTERNAL_SERVER_ERROR) + + /** + * Indicates DPoP Nonce must be used. + */ + class UseDPoPNonce( + description: String, + val accessToken: DPoPAccessToken, + ) : DPoPTokenError("use_dpop_nonce", description, HttpStatus.UNAUTHORIZED) + companion object { /** * Creates a new 'invalid request' error. */ - fun invalidRequest(description: String): DPoPTokenError = - DPoPTokenError(OAuth2ErrorCodes.INVALID_REQUEST, description, HttpStatus.BAD_REQUEST) + fun invalidRequest(description: String): InvalidRequest = InvalidRequest(description) /** * Creates a new 'invalid token' error. */ - fun invalidToken(description: String): DPoPTokenError = - DPoPTokenError(OAuth2ErrorCodes.INVALID_TOKEN, description, HttpStatus.UNAUTHORIZED) + fun invalidToken(description: String): InvalidToken = InvalidToken(description) /** * Creates a new 'server error' error. */ - fun serverError(description: String): DPoPTokenError = - DPoPTokenError(OAuth2ErrorCodes.SERVER_ERROR, description, HttpStatus.INTERNAL_SERVER_ERROR) + fun serverError(description: String, error: Throwable): ServerError = ServerError(description, error) + + /** + * Creates a new 'use dpop nonce' error. + */ + fun useDPoPNonce(description: String, accessToken: DPoPAccessToken): UseDPoPNonce = UseDPoPNonce(description, accessToken) } } diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenReactiveAuthenticationManager.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenReactiveAuthenticationManager.kt index 3fd53d8c..6e9ad27e 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenReactiveAuthenticationManager.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenReactiveAuthenticationManager.kt @@ -22,7 +22,8 @@ import com.nimbusds.oauth2.sdk.dpop.verifiers.DPoPProtectedResourceRequestVerifi import com.nimbusds.oauth2.sdk.dpop.verifiers.InvalidDPoPProofException import com.nimbusds.oauth2.sdk.id.ClientID import com.nimbusds.oauth2.sdk.token.DPoPAccessToken -import com.nimbusds.oauth2.sdk.util.JSONObjectUtils +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.mono import net.minidev.json.JSONObject import org.springframework.security.authentication.ReactiveAuthenticationManager import org.springframework.security.core.Authentication @@ -32,7 +33,6 @@ import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNam import org.springframework.security.oauth2.server.resource.introspection.BadOpaqueTokenException import org.springframework.security.oauth2.server.resource.introspection.SpringReactiveOpaqueTokenIntrospector import reactor.core.publisher.Mono -import reactor.kotlin.core.publisher.switchIfEmpty /** * [ReactiveAuthenticationManager] implementing DPoP authentication. @@ -42,42 +42,38 @@ import reactor.kotlin.core.publisher.switchIfEmpty class DPoPTokenReactiveAuthenticationManager( private val introspector: SpringReactiveOpaqueTokenIntrospector, private val verifier: DPoPProtectedResourceRequestVerifier, + private val dpopNonce: DPoPNoncePolicy, ) : ReactiveAuthenticationManager { /** * Performs DPoP authentication. */ override fun authenticate(authentication: Authentication): Mono = - Mono.just(authentication) - .filter { it is DPoPTokenAuthentication } - .map { it as DPoPTokenAuthentication } - .flatMap { dPoPAuthentication -> - introspect(dPoPAuthentication.accessToken) - .flatMap { principal -> - val issuer = principal.issuer() - val thumbprint = principal.jwkThumbprint() - Mono.zip(issuer, thumbprint) - .flatMap { - verify(dPoPAuthentication, it.t1, it.t2) - .then(Mono.just(dPoPAuthentication.authenticate(principal))) - } - } + mono { + when (authentication) { + is DPoPTokenAuthentication -> { + val principal = introspect(authentication.accessToken) + val issuer = principal.issuer() + val thumbprint = principal.jwkThumbprint() + val dpopNonce = dpopNonce.getActiveOrGenerateNew(authentication.accessToken) + verify(authentication, issuer, thumbprint, dpopNonce) + authentication.authenticate(principal) + } + else -> null } + } /** * Introspects the provided [token] and verifies whether it's active or not. */ - private fun introspect(token: DPoPAccessToken): Mono = - introspector.introspect(token.value) - .onErrorMap { exception -> - val error = - if (exception is BadOpaqueTokenException) { - DPoPTokenError.invalidToken("Access token is not valid") - } else { - DPoPTokenError.serverError("Unable to introspect access token") - } - OAuth2AuthenticationException(error, exception) - } + private suspend fun introspect(token: DPoPAccessToken): OAuth2AuthenticatedPrincipal = + try { + introspector.introspect(token.value).awaitSingle() + } catch (exception: BadOpaqueTokenException) { + throw OAuth2AuthenticationException(DPoPTokenError.invalidToken("Access token is not valid"), exception) + } catch (exception: Exception) { + throw OAuth2AuthenticationException(DPoPTokenError.serverError("Unable to introspect access token", exception), exception) + } /** * Given a [DPoPTokenAuthentication], the [DPoPIssuer], and the [JWKThumbprintConfirmation], performs DPoP verification @@ -87,8 +83,9 @@ class DPoPTokenReactiveAuthenticationManager( authentication: DPoPTokenAuthentication, issuer: DPoPIssuer, thumbprint: JWKThumbprintConfirmation, - ): Mono = - Mono.fromCallable { + dpopNonce: DPoPNonce?, + ) { + try { verifier.verify( authentication.method.name(), authentication.uri, @@ -96,42 +93,52 @@ class DPoPTokenReactiveAuthenticationManager( authentication.dpop, authentication.accessToken, thumbprint, - null, + dpopNonce?.nonce, + ) + } catch (exception: InvalidDPoPProofException) { + val error = if (exception.message?.contains("nonce", ignoreCase = true) == true) { + DPoPTokenError.useDPoPNonce("Invalid DPoP proof '${exception.message}'.", authentication.accessToken) + } else { + DPoPTokenError.invalidToken("Invalid DPoP proof '${exception.message}'.") + } + throw OAuth2AuthenticationException(error, exception) + } catch (exception: AccessTokenValidationException) { + throw OAuth2AuthenticationException( + DPoPTokenError.invalidToken("Invalid access token binding '${exception.message}'."), + exception, + ) + } catch (exception: Exception) { + throw OAuth2AuthenticationException( + DPoPTokenError.serverError("Unable to verify DPoP proof '${exception.message}'", exception), + exception, ) - }.onErrorMap { exception -> - val error = - when (exception) { - is InvalidDPoPProofException -> DPoPTokenError.invalidToken("Invalid DPoP proof '${exception.message}'.") - is AccessTokenValidationException -> DPoPTokenError.invalidToken("Invalid access token binding '${exception.message}'.") - else -> DPoPTokenError.serverError("Unable to verify DPoP proof '${exception.message}'") - } - OAuth2AuthenticationException(error, exception) } + } } /** * Gets the [DPoPIssuer] from this [OAuth2AuthenticatedPrincipal]. */ -private fun OAuth2AuthenticatedPrincipal.issuer(): Mono = - Mono.justOrEmpty(attributes[OAuth2TokenIntrospectionClaimNames.CLIENT_ID]) - .filter { it is String && it.isNotBlank() } - .map { DPoPIssuer(ClientID(it as String)) } - .switchIfEmpty { - val error = DPoPTokenError.invalidToken("Unable to determine DPoP issuer") - Mono.error(OAuth2AuthenticationException(error)) +private fun OAuth2AuthenticatedPrincipal.issuer(): DPoPIssuer = + attributes[OAuth2TokenIntrospectionClaimNames.CLIENT_ID]?.let { clientId -> + if (clientId is String && clientId.isNotBlank()) { + DPoPIssuer(ClientID(clientId)) + } else { + null } + } ?: throw OAuth2AuthenticationException(DPoPTokenError.invalidToken("Unable to determine DPoP issuer")) /** * Gets the [JWKThumbprintConfirmation] from this [OAuth2AuthenticatedPrincipal]. */ -private fun OAuth2AuthenticatedPrincipal.jwkThumbprint(): Mono = - Mono.fromCallable { JSONObjectUtils.parse(JSONObject.toJSONString(attributes.filterKeys { it == "cnf" })) } - .flatMap { Mono.fromCallable { JWKThumbprintConfirmation.parse(it) } } - .onErrorMap { - val error = DPoPTokenError.serverError("Unable to extract DPoP configuration") - OAuth2AuthenticationException(error, it) - } - .switchIfEmpty { - val error = DPoPTokenError.invalidToken("Access token is not DPoP bound") - Mono.error(OAuth2AuthenticationException(error)) - } +private fun OAuth2AuthenticatedPrincipal.jwkThumbprint(): JWKThumbprintConfirmation { + val cnf = attributes.filterKeys { it == "cnf" } + .takeIf { it.isNotEmpty() } + ?: throw OAuth2AuthenticationException(DPoPTokenError.invalidToken("Access token is not DPoP bound")) + + return try { + JWKThumbprintConfirmation.parse(JSONObject(cnf)) + } catch (exception: Exception) { + throw OAuth2AuthenticationException(DPoPTokenError.serverError("Unable to extract DPoP confirmation", exception)) + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAccessDeniedHandler.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAccessDeniedHandler.kt index 29abdb67..36b27c43 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAccessDeniedHandler.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAccessDeniedHandler.kt @@ -16,6 +16,8 @@ package eu.europa.ec.eudi.pidissuer.adapter.input.web.security import com.nimbusds.oauth2.sdk.token.AccessTokenType +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.reactor.mono import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.security.access.AccessDeniedException @@ -32,22 +34,24 @@ class DPoPTokenServerAccessDeniedHandler( private val realm: String? = null, ) : ServerAccessDeniedHandler { - override fun handle(exchange: ServerWebExchange, denied: AccessDeniedException): Mono { - val details = buildList { - if (!realm.isNullOrBlank()) { - add("realm" to realm) - } - add("error" to OAuth2ErrorCodes.INSUFFICIENT_SCOPE) - add("error_description" to "The request requires higher privileges than provided by the access token.") - add("error_uri" to "https://tools.ietf.org/html/rfc6750#section-3.1") - }.joinToString(separator = ", ", transform = { "${it.first}=\"${it.second}\"" }) - val wwwAuthenticate = "${AccessTokenType.DPOP.value} $details" + override fun handle(exchange: ServerWebExchange, denied: AccessDeniedException): Mono = + mono { + val details = buildList { + if (!realm.isNullOrBlank()) { + add("realm" to realm) + } + add("error" to OAuth2ErrorCodes.INSUFFICIENT_SCOPE) + add("error_description" to "The request requires higher privileges than provided by the access token.") + add("error_uri" to "https://tools.ietf.org/html/rfc6750#section-3.1") + }.joinToString(separator = ", ", transform = { "${it.first}=\"${it.second}\"" }) + val wwwAuthenticate = "${AccessTokenType.DPOP.value} $details" - return exchange.response - .apply { - statusCode = HttpStatus.FORBIDDEN - headers[HttpHeaders.WWW_AUTHENTICATE] = wwwAuthenticate - } - .setComplete() - } + exchange.response + .apply { + statusCode = HttpStatus.FORBIDDEN + headers[HttpHeaders.WWW_AUTHENTICATE] = wwwAuthenticate + } + .setComplete() + .awaitSingleOrNull() + } } diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAuthenticationEntryPoint.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAuthenticationEntryPoint.kt index ba1952cb..934f565a 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAuthenticationEntryPoint.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/DPoPTokenServerAuthenticationEntryPoint.kt @@ -16,6 +16,8 @@ package eu.europa.ec.eudi.pidissuer.adapter.input.web.security import com.nimbusds.oauth2.sdk.token.AccessTokenType +import kotlinx.coroutines.reactor.awaitSingleOrNull +import kotlinx.coroutines.reactor.mono import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.security.core.AuthenticationException @@ -30,23 +32,43 @@ import reactor.core.publisher.Mono */ class DPoPTokenServerAuthenticationEntryPoint( private val realm: String? = null, + private val dpopNonce: DPoPNoncePolicy, ) : ServerAuthenticationEntryPoint { - override fun commence(exchange: ServerWebExchange, ex: AuthenticationException): Mono { - val details = buildList { - if (!realm.isNullOrBlank()) { - add("realm" to realm) - } - addAll(ex.details()) - }.joinToString(separator = ", ", transform = { "${it.first}=\"${it.second}\"" }) - val wwwAuthenticate = "${AccessTokenType.DPOP.value} $details" - return exchange.response - .apply { - statusCode = ex.status() - headers[HttpHeaders.WWW_AUTHENTICATE] = wwwAuthenticate - } - .setComplete() - } + override fun commence(exchange: ServerWebExchange, ex: AuthenticationException): Mono = + mono { + val details = buildList { + if (!realm.isNullOrBlank()) { + add("realm" to realm) + } + addAll(ex.details()) + }.joinToString(separator = ", ", transform = { "${it.first}=\"${it.second}\"" }) + val wwwAuthenticate = "${AccessTokenType.DPOP.value} $details" + val dpopNonce = ex.dpopNonce() + exchange.response + .apply { + statusCode = ex.status() + headers[HttpHeaders.WWW_AUTHENTICATE] = wwwAuthenticate + dpopNonce?.let { + headers["DPoP-Nonce"] = it.nonce.value + } + } + .setComplete() + .awaitSingleOrNull() + } + + /** + * Generates a new [DPoPNonce] in case this [AuthenticationException] contains a [DPoPTokenError.UseDPoPNonce] error. + */ + private suspend fun AuthenticationException.dpopNonce(): DPoPNonce? = + when (this) { + is OAuth2AuthenticationException -> + when (val error = error) { + is DPoPTokenError.UseDPoPNonce -> dpopNonce.getActiveOrGenerateNew(error.accessToken) + else -> null + } + else -> null + } } /** diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/ServerDPoPAuthenticationTokenAuthenticationConverter.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/ServerDPoPAuthenticationTokenAuthenticationConverter.kt index 03c8e8b9..550ee628 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/ServerDPoPAuthenticationTokenAuthenticationConverter.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/security/ServerDPoPAuthenticationTokenAuthenticationConverter.kt @@ -18,6 +18,7 @@ package eu.europa.ec.eudi.pidissuer.adapter.input.web.security import com.nimbusds.jwt.SignedJWT import com.nimbusds.oauth2.sdk.token.AccessTokenType import com.nimbusds.oauth2.sdk.token.DPoPAccessToken +import kotlinx.coroutines.reactor.mono import org.springframework.http.HttpHeaders import org.springframework.http.server.reactive.ServerHttpRequest import org.springframework.security.core.Authentication @@ -33,74 +34,78 @@ import java.net.URI */ class ServerDPoPAuthenticationTokenAuthenticationConverter : ServerAuthenticationConverter { - override fun convert(exchange: ServerWebExchange): Mono { - val request = exchange.request - val dpop = request.dPoP() - val authorization = request.authorization() - val method = request.method - val uri = request.uri() + override fun convert(exchange: ServerWebExchange): Mono = + mono { + try { + val request = exchange.request + val dpop = request.dPoP() + val authorization = request.authorization() + val uri = request.uri() - return Mono.zip(dpop, authorization, uri) - .onErrorMap { - when (it) { - is OAuth2AuthenticationException -> it - else -> { - val error = DPoPTokenError.serverError("Unable to process DPoP request") - OAuth2AuthenticationException(error, it) - } + if (dpop != null && authorization != null) { + DPoPTokenAuthentication.unauthenticated(dpop, authorization, request.method, uri) + } else { + null } + } catch (exception: OAuth2AuthenticationException) { + throw exception + } catch (exception: Exception) { + throw OAuth2AuthenticationException( + DPoPTokenError.serverError( + "Unable to process DPoP request", + exception, + ), + exception, + ) } - .map { DPoPTokenAuthentication.unauthenticated(it.t1, it.t2, method, it.t3) } - } + } } /** * Gets the value of a [header]. The header is expected to have at most 1 value. If more are found, an error is returned. */ -private fun HttpHeaders.singleValueHeader(header: String): Mono { +private fun HttpHeaders.singleValueHeader(header: String): String? { val values = this[header] return when { - values.isNullOrEmpty() -> Mono.empty() - values.size == 1 -> Mono.justOrEmpty(values[0]) - else -> { - val error = DPoPTokenError.invalidRequest("Multiple '$values' header values found") - Mono.error(OAuth2AuthenticationException(error)) - } + values.isNullOrEmpty() -> null + values.size == 1 -> values[0] + else -> throw OAuth2AuthenticationException(DPoPTokenError.invalidRequest("Multiple '$values' header values found")) } } /** * Gets the DPoP header value, if any, and parses it as a [SignedJWT]. */ -private fun ServerHttpRequest.dPoP() = headers.singleValueHeader(AccessTokenType.DPOP.value) - .filter { !it.isNullOrBlank() } - .flatMap { - Mono.fromCallable { SignedJWT.parse(it) } - .onErrorMap { - val error = - DPoPTokenError.invalidRequest("'${AccessTokenType.DPOP.value}' header is not a valid signed JWT") - OAuth2AuthenticationException(error) +private fun ServerHttpRequest.dPoP(): SignedJWT? = + headers.singleValueHeader(AccessTokenType.DPOP.value) + ?.takeIf { it.isNotBlank() } + ?.let { + try { + SignedJWT.parse(it) + } catch (error: Exception) { + throw OAuth2AuthenticationException( + DPoPTokenError.invalidRequest("'${AccessTokenType.DPOP.value}' header is not a valid signed JWT"), + ) } - } + } /** * Gets the Authorization header value, if any. */ -private fun ServerHttpRequest.authorization() = headers.singleValueHeader(HttpHeaders.AUTHORIZATION) - .filter { !it.isNullOrBlank() && it.startsWith(AccessTokenType.DPOP.value) } - .flatMap { - Mono.fromCallable { DPoPAccessToken.parse(it) } - .onErrorMap { - val error = - DPoPTokenError.invalidRequest("'${HttpHeaders.AUTHORIZATION}' header is not a valid DPoP access token") - OAuth2AuthenticationException(error) +private fun ServerHttpRequest.authorization(): DPoPAccessToken? = + headers.singleValueHeader(HttpHeaders.AUTHORIZATION) + ?.takeIf { it.isNotBlank() && it.startsWith(AccessTokenType.DPOP.value) } + ?.let { + try { + DPoPAccessToken.parse(it) + } catch (error: Exception) { + throw OAuth2AuthenticationException( + DPoPTokenError.invalidRequest("'${HttpHeaders.AUTHORIZATION}' header is not a valid DPoP access token"), + ) } - } + } /** * Gets the uri of the current [ServerHttpRequest]. The uri does not contain query parameters or fragments. */ -private fun ServerHttpRequest.uri() = Mono.fromCallable { - val uri = UriComponentsBuilder.fromUri(uri).query(null).fragment(null).toUriString() - URI.create(uri) -} +private fun ServerHttpRequest.uri(): URI = URI.create(UriComponentsBuilder.fromUri(uri).query(null).fragment(null).toUriString()) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cff21221..ada76b50 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -38,6 +38,8 @@ issuer.signing-key=GenerateRandom issuer.dpop.proof-max-age=PT1M issuer.dpop.cache-purge-interval=PT10M issuer.dpop.realm=pid-issuer +issuer.dpop.nonce.enabled=false +issuer.dpop.nonce.expiration=PT5M issuer.credentialEndpoint.batchIssuance.enabled=true issuer.credentialEndpoint.batchIssuance.batchSize=10