Skip to content

Commit

Permalink
Ktor 3 (#974)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorweber authored Nov 26, 2024
1 parent 7409be4 commit 252ab9b
Show file tree
Hide file tree
Showing 13 changed files with 1,038 additions and 4 deletions.
9 changes: 5 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@
<modules>
<module>token-validation-core</module>
<module>token-validation-filter</module>
<module>token-validation-spring</module>
<module>token-validation-spring</module>
<module>token-validation-spring-test</module>
<module>token-validation-jaxrs</module>
<module>token-validation-spring-demo</module>
<module>token-validation-ktor-v2</module>
<module>token-validation-ktor-v3</module>
<module>token-validation-ktor-demo</module>
<module>token-client-spring</module>
<module>token-client-spring-demo</module>
Expand All @@ -43,7 +44,6 @@
<properties>
<dokka.version>1.9.20</dokka.version>
<kotlin.version>1.9.25</kotlin.version>
<kotlin-coroutines.version>1.6.2</kotlin-coroutines.version>
<doclint>none</doclint>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.organization>navikt</sonar.organization>
Expand All @@ -60,6 +60,7 @@
<caffeine.version>3.1.8</caffeine.version>
<okhttp3.version>4.12.0</okhttp3.version>
<ktor.version>2.3.12</ktor.version>
<ktor3.version>3.0.1</ktor3.version>
<kotlin.code.style>official</kotlin.code.style>
<mock-oauth2-server.version>2.1.10</mock-oauth2-server.version>
<nimbus.jose.jwt.version>9.47</nimbus.jose.jwt.version>
Expand Down Expand Up @@ -163,8 +164,8 @@
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jetbrains.dokka</groupId>
<artifactId>dokka-maven-plugin</artifactId>
<groupId>org.jetbrains.dokka</groupId>
<artifactId>dokka-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Expand Down
24 changes: 24 additions & 0 deletions token-validation-ktor-v3/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
target/
!.mvn/wrapper/maven-wrapper.jar

### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans

### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr

### NetBeans ###
nbproject/private/
build/
nbbuild/
dist/
nbdist/
.nb-gradle/
116 changes: 116 additions & 0 deletions token-validation-ktor-v3/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?xml version="1.0"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>no.nav.security</groupId>
<artifactId>token-support</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>token-validation-ktor-v3</artifactId>
<name>token-validation-ktor-v3</name>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>token-validation-core</artifactId>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server</artifactId>
<version>${ktor3.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-core-jvm</artifactId>
<version>1.9.0</version>
</dependency>
<!-- Test dependencies: -->
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-netty-jvm</artifactId>
<version>${ktor3.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-test-host-jvm</artifactId>
<version>${ktor3.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-standalone</artifactId>
<version>3.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</dependency>
<dependency>
<!-- needed by Wiremock -->
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.3.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-assertions-core-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-runner-junit5-jvm</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>no.nav.security</groupId>
<artifactId>mock-oauth2-server</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.ktor</groupId>
<artifactId>ktor-server-auth-jvm</artifactId>
<version>${ktor3.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-test-jvm</artifactId>
<version>1.9.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-gpg-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package no.nav.security.token.support.v3

import com.nimbusds.jwt.JWTClaimNames.EXPIRATION_TIME
import io.ktor.server.application.*
import io.ktor.server.response.*
import java.time.LocalDateTime.now
import java.time.LocalDateTime.ofInstant
import java.time.ZoneId.systemDefault
import java.time.temporal.ChronoUnit.MINUTES
import java.util.*
import no.nav.security.token.support.core.JwtTokenConstants.TOKEN_EXPIRES_SOON_HEADER
import no.nav.security.token.support.core.context.TokenValidationContext
import no.nav.security.token.support.core.jwt.JwtTokenClaims
import org.slf4j.LoggerFactory

class JwtTokenExpiryThresholdHandler(private val expiryThreshold: Int) {

private val log = LoggerFactory.getLogger(JwtTokenExpiryThresholdHandler::class.java.name)

fun addHeaderOnTokenExpiryThreshold(call: ApplicationCall, ctx: TokenValidationContext) {
if(expiryThreshold > 0) {
ctx.issuers.forEach {
if (tokenExpiresBeforeThreshold(ctx.getClaims(it))) {
call.response.header(TOKEN_EXPIRES_SOON_HEADER, "true")
} else {
log.debug("Token is still within expiry threshold.")
}
}
} else {
log.debug("Expiry threshold is not set")
}
}

private fun tokenExpiresBeforeThreshold(jwtTokenClaims: JwtTokenClaims): Boolean {
val expiryDate = jwtTokenClaims.get(EXPIRATION_TIME) as Date
val expiry = ofInstant(expiryDate.toInstant(), systemDefault())
val minutesUntilExpiry = now().until(expiry, MINUTES)
log.debug("Checking token at time {} with expirationTime {} for how many minutes until expiry: {}",
now(), expiry, minutesUntilExpiry)
if (minutesUntilExpiry <= expiryThreshold) {
log.debug("There are {} minutes until expiry which is equal to or less than the configured threshold {}",
minutesUntilExpiry, expiryThreshold)
return true
}
return false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package no.nav.security.token.support.v3


import com.nimbusds.jose.util.ResourceRetriever
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.config.*
import io.ktor.server.response.*
import java.net.URI
import no.nav.security.token.support.core.JwtTokenConstants.AUTHORIZATION_HEADER
import no.nav.security.token.support.core.configuration.IssuerProperties
import no.nav.security.token.support.core.configuration.IssuerProperties.JwksCache
import no.nav.security.token.support.core.configuration.IssuerProperties.Validation
import no.nav.security.token.support.core.configuration.MultiIssuerConfiguration
import no.nav.security.token.support.core.configuration.ProxyAwareResourceRetriever
import no.nav.security.token.support.core.context.TokenValidationContext
import no.nav.security.token.support.core.context.TokenValidationContextHolder
import no.nav.security.token.support.core.exceptions.JwtTokenInvalidClaimException
import no.nav.security.token.support.core.exceptions.JwtTokenMissingException
import no.nav.security.token.support.core.http.HttpRequest
import no.nav.security.token.support.core.utils.JwtTokenUtil.getJwtToken
import no.nav.security.token.support.core.validation.JwtTokenAnnotationHandler
import no.nav.security.token.support.core.validation.JwtTokenValidationHandler
import no.nav.security.token.support.v3.TokenSupportAuthenticationProvider.ProviderConfiguration
import org.slf4j.LoggerFactory

data class TokenValidationContextPrincipal(val context: TokenValidationContext) : Principal

private val log = LoggerFactory.getLogger(TokenSupportAuthenticationProvider::class.java.name)

class TokenSupportAuthenticationProvider(providerConfig: ProviderConfiguration, config: ApplicationConfig,
private val requiredClaims: RequiredClaims? = null,
private val additionalValidation: ((TokenValidationContext) -> Boolean)? = null,
resourceRetriever: ResourceRetriever) : AuthenticationProvider(providerConfig) {

private val jwtTokenValidationHandler: JwtTokenValidationHandler
private val jwtTokenExpiryThresholdHandler: JwtTokenExpiryThresholdHandler

init {
jwtTokenValidationHandler = JwtTokenValidationHandler(MultiIssuerConfiguration(config.asIssuerProps(), resourceRetriever))

val expiryThreshold = config.propertyOrNull("no.nav.security.jwt.expirythreshold")?.getString()?.toInt() ?: -1
jwtTokenExpiryThresholdHandler = JwtTokenExpiryThresholdHandler(expiryThreshold)
}

class ProviderConfiguration internal constructor(name: String?) : Config(name)

override suspend fun onAuthenticate(context: AuthenticationContext) {
val applicationCall = context.call
val tokenValidationContext = jwtTokenValidationHandler.getValidatedTokens(
JwtTokenHttpRequest( applicationCall.request.headers)
)
try {
if (tokenValidationContext.hasValidToken()) {
if (requiredClaims != null) {
RequiredClaimsHandler(InternalTokenValidationContextHolder(tokenValidationContext)).handleRequiredClaims(
requiredClaims
)
}
if (additionalValidation != null) {
if (!additionalValidation.invoke(tokenValidationContext)) {
throw AdditionalValidationReturnedFalse()
}
}
jwtTokenExpiryThresholdHandler.addHeaderOnTokenExpiryThreshold(applicationCall, tokenValidationContext)
context.principal(TokenValidationContextPrincipal(tokenValidationContext))
}
} catch (e: Throwable) {
log.trace("Token verification failed: {}", e.message ?: e.javaClass.simpleName)
}
context.challenge("JWTAuthKey", AuthenticationFailedCause.InvalidCredentials) { authenticationProcedureChallenge, call ->
call.respond(UnauthorizedResponse())
authenticationProcedureChallenge.complete()
}
}
}

fun AuthenticationConfig.tokenValidationSupport(name: String? = null, config: ApplicationConfig, requiredClaims: RequiredClaims? = null,
additionalValidation: ((TokenValidationContext) -> Boolean)? = null,
resourceRetriever: ResourceRetriever = ProxyAwareResourceRetriever(System.getenv("HTTP_PROXY")?.let { URI.create(it).toURL() })) {
register(TokenSupportAuthenticationProvider(ProviderConfiguration(name), config, requiredClaims, additionalValidation, resourceRetriever))
}


data class RequiredClaims(val issuer: String, val claimMap: Array<String>, val combineWithOr: Boolean = false)

data class IssuerConfig(
val name: String,
val discoveryUrl: String,
val acceptedAudience: List<String> = emptyList(),
val optionalClaims: List<String> = emptyList(),
)

class TokenSupportConfig(vararg issuers: IssuerConfig) : MapApplicationConfig(
*(issuers.mapIndexed { index, issuerConfig ->
listOf(
"no.nav.security.jwt.issuers.$index.issuer_name" to issuerConfig.name,
"no.nav.security.jwt.issuers.$index.discoveryurl" to issuerConfig.discoveryUrl,
"no.nav.security.jwt.issuers.$index.accepted_audience" to
issuerConfig.acceptedAudience.joinToString(","),
"no.nav.security.jwt.issuers.$index.validation.optional_claims" to
issuerConfig.optionalClaims.joinToString(","),
)
}.flatten().plus("no.nav.security.jwt.issuers.size" to issuers.size.toString()).toTypedArray())
)

private class InternalTokenValidationContextHolder(private var tokenValidationContext: TokenValidationContext) : TokenValidationContextHolder {
override fun getTokenValidationContext() = tokenValidationContext
override fun setTokenValidationContext(tokenValidationContext: TokenValidationContext?) {
tokenValidationContext?.let { this.tokenValidationContext = tokenValidationContext }
}
}

internal class AdditionalValidationReturnedFalse : RuntimeException()

internal class RequiredClaimsException(message: String, cause: Throwable) : RuntimeException(message, cause)
internal class RequiredClaimsHandler(private val tokenValidationContextHolder: TokenValidationContextHolder) : JwtTokenAnnotationHandler(tokenValidationContextHolder) {
internal fun handleRequiredClaims(requiredClaims: RequiredClaims) {
runCatching {
with(requiredClaims) {
log.debug("Checking required claims for issuer: {}, claims: {}, combineWithOr: {}", issuer, claimMap, combineWithOr)
val jwtToken = getJwtToken(issuer, tokenValidationContextHolder)
if (jwtToken.isEmpty) {
throw JwtTokenMissingException("No valid token found in validation context")
}
if (!handleProtectedWithClaims(issuer, claimMap, combineWithOr, jwtToken.get()))
throw JwtTokenInvalidClaimException("Required claims not present in token." + requiredClaims.claimMap)
}
}.getOrElse { e -> throw RequiredClaimsException(e.message ?: "", e) }
}
}

internal data class JwtTokenHttpRequest(private val headers: Headers) : HttpRequest {
override fun getHeader(headerName: String) = headers[headerName]
}

fun ApplicationConfig.asIssuerProps(): Map<String, IssuerProperties> = configList("no.nav.security.jwt.issuers")
.associate {
it.property("issuer_name").getString() to IssuerProperties(
URI.create(it.property("discoveryurl").getString()).toURL(),
it.propertyOrNull("accepted_audience")?.getString()
?.split(",")
?.filter { aud -> aud.isNotEmpty() }
?: emptyList(),
null,
it.propertyOrNull("header_name")?.getString() ?: AUTHORIZATION_HEADER,
Validation(it.propertyOrNull("validation.optional_claims")?.getString()
?.split(",")
?.filter { claim -> claim.isNotEmpty() }
?: emptyList()),
JwksCache(it.propertyOrNull("jwks_cache.lifespan")?.getString()?.toLong() ?: 15, it.propertyOrNull("jwks_cache.refreshtime")?.getString()?.toLong() ?: 5))
}
Loading

0 comments on commit 252ab9b

Please sign in to comment.