Skip to content

Commit

Permalink
DPoP: Resource-Server provided Nonce (#229)
Browse files Browse the repository at this point in the history
  • Loading branch information
dzarras authored Nov 4, 2024
1 parent 182b73c commit 7f3a7b4
Show file tree
Hide file tree
Showing 10 changed files with 431 additions and 145 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -836,6 +857,7 @@ fun BeanDefinitionDsl.initializer(): ApplicationContextInitializer<GenericApplic
ApplicationContextInitializer<GenericApplicationContext> { initialize(it) }

@SpringBootApplication
@EnableScheduling
class PidIssuerApplication

fun main(args: Array<String>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DPoPAccessToken, DPoPNonce>()
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
}
}
Original file line number Diff line number Diff line change
@@ -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<Void> =
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,37 +15,66 @@
*/
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

/**
* 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)
}
}
Loading

0 comments on commit 7f3a7b4

Please sign in to comment.