diff --git a/pom.xml b/pom.xml index 95af156f..021a6df4 100644 --- a/pom.xml +++ b/pom.xml @@ -29,11 +29,12 @@ token-validation-core token-validation-filter - token-validation-spring + token-validation-spring token-validation-spring-test token-validation-jaxrs token-validation-spring-demo token-validation-ktor-v2 + token-validation-ktor-v3 token-validation-ktor-demo token-client-spring token-client-spring-demo @@ -43,7 +44,6 @@ 1.9.20 1.9.25 - 1.6.2 none https://sonarcloud.io navikt @@ -60,6 +60,7 @@ 3.1.8 4.12.0 2.3.12 + 3.0.1 official 2.1.10 9.47 @@ -163,8 +164,8 @@ maven-surefire-plugin - org.jetbrains.dokka - dokka-maven-plugin + org.jetbrains.dokka + dokka-maven-plugin diff --git a/token-validation-ktor-v3/.gitignore b/token-validation-ktor-v3/.gitignore new file mode 100644 index 00000000..f94d4027 --- /dev/null +++ b/token-validation-ktor-v3/.gitignore @@ -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/ diff --git a/token-validation-ktor-v3/pom.xml b/token-validation-ktor-v3/pom.xml new file mode 100644 index 00000000..fa1171bf --- /dev/null +++ b/token-validation-ktor-v3/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + no.nav.security + token-support + 3.0.0-SNAPSHOT + + token-validation-ktor-v3 + token-validation-ktor-v3 + + + ${project.groupId} + token-validation-core + + + io.ktor + ktor-server + ${ktor3.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-core-jvm + 1.9.0 + + + + io.ktor + ktor-server-netty-jvm + ${ktor3.version} + test + + + io.ktor + ktor-server-test-host-jvm + ${ktor3.version} + test + + + junit + junit + + + + + org.wiremock + wiremock-standalone + 3.9.2 + test + + + jakarta.servlet + jakarta.servlet-api + + + + commons-logging + commons-logging + 1.3.4 + test + + + io.kotest + kotest-assertions-core-jvm + test + + + io.kotest + kotest-runner-junit5-jvm + test + + + no.nav.security + mock-oauth2-server + test + + + io.ktor + ktor-server-auth-jvm + ${ktor3.version} + + + org.jetbrains.kotlinx + kotlinx-coroutines-test-jvm + 1.9.0 + test + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/test/kotlin + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + + + + + + \ No newline at end of file diff --git a/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/JwtTokenExpiryThresholdHandler.kt b/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/JwtTokenExpiryThresholdHandler.kt new file mode 100644 index 00000000..7282ac6e --- /dev/null +++ b/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/JwtTokenExpiryThresholdHandler.kt @@ -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 + } +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProvider.kt b/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProvider.kt new file mode 100644 index 00000000..78add7af --- /dev/null +++ b/token-validation-ktor-v3/src/main/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProvider.kt @@ -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, val combineWithOr: Boolean = false) + +data class IssuerConfig( + val name: String, + val discoveryUrl: String, + val acceptedAudience: List = emptyList(), + val optionalClaims: List = 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 = 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)) + } \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/ApplicationTest.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/ApplicationTest.kt new file mode 100644 index 00000000..64e334c5 --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/ApplicationTest.kt @@ -0,0 +1,297 @@ +package no.nav.security.token.support.v3 + +import com.nimbusds.jwt.JWTClaimNames.AUDIENCE +import com.nimbusds.jwt.JWTClaimNames.SUBJECT +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import no.nav.security.mock.oauth2.MockOAuth2Server +import no.nav.security.token.support.core.JwtTokenConstants.AUTHORIZATION_HEADER +import no.nav.security.token.support.v3.testapp.module +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.slf4j.LoggerFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplicationTest { + + private companion object { + private val logger = LoggerFactory.getLogger(ApplicationTest::class.java) + + const val ISSUER_ID = "default" + const val ACCEPTED_AUDIENCE = "default" + + val server = MockOAuth2Server() + + @BeforeAll + @JvmStatic + fun before() { + logger.info("Starting up MockOAuth2Server...") + server.start() + } + + @AfterAll + @JvmStatic + fun after() { + logger.info("Tearing down MockOAuth2Server...") + server.shutdown() + } + } + + @Test + fun hello_withMissingJWTShouldGive_401_Unauthorized_andHelloCounterIsNOTIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello") + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun hello_withJWTWithUnknownIssuerShouldGive_401_Unauthorized_andHelloCounterIsNOTIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello") { + val jwt = server.issueToken(issuerId = "unknown", subject = "testuser") + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun hello_withValidJWTinHeaderShouldGive_200_OK_andHelloCounterIsIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello") { + val jwt = server.issueToken(issuerId = ISSUER_ID, subject = "testuser") + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun `token without sub should NOT be accepted if NOT configured as optional claim`() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello") { + val jwt = server.anyToken( + server.issuerUrl(ISSUER_ID), + mapOf(AUDIENCE to ACCEPTED_AUDIENCE) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `token without sub should be accepted if configured as optional claim`() = testApplication { + environment { + config = doConfig().apply { + put("no.nav.security.jwt.issuers.0.validation.optional_claims", SUBJECT) + } + } + + application { + module() + } + + val response = client.get("/hello") { + val jwt = server.anyToken( + server.issuerUrl(ISSUER_ID), + mapOf(AUDIENCE to ACCEPTED_AUDIENCE) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + @Test + fun helloPerson_withMissingRequiredClaimShouldGive_401_andHelloCounterIsNotIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello_person") { + val jwt = server.issueToken(issuerId = ISSUER_ID, subject = "testuser") + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun helloPerson_withRequiredClaimShouldGive_200_OK_andHelloCounterIsIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello_person") { + val jwt = server.issueToken( + issuerId = ISSUER_ID, + subject = "testuser", + claims = mapOf("NAVident" to "X112233") + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + + @Test + fun openhello_withMissingJWTShouldGive_200_andOpenHelloCounterIsIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/openhello") { + } + assertEquals(HttpStatusCode.OK, response.status) + } + + + @Test + fun helloGroup_withoutRequiredGroup_ShouldGive_401_OK_andHelloGroupCounterIsNOTIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello_group") { + val jwt = server.issueToken( + issuerId = ISSUER_ID, + subject = "testuser", + claims = mapOf( + "NAVident" to "X112233", + "groups" to listOf("group1", "group2") + ) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + + @Test + fun helloGroup_withNoGroupClaim_ShouldGive_401_andHelloGroupCounterIsNOTIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello_group") { + val jwt = server.issueToken( + issuerId = ISSUER_ID, + subject = "testuser", + claims = mapOf( + "NAVident" to "X112233", + ) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + + @Test + fun helloGroup_withRequiredGroup_ShouldGive_200_OK_andHelloGroupCounterIsIncreased() = testApplication { + environment { + config = doConfig() + } + + application { + module() + } + + val response = client.get("/hello_group") { + val jwt = server.issueToken( + issuerId = ISSUER_ID, + subject = "testuser", + claims = mapOf( + "NAVident" to "X112233", + "groups" to listOf("group1", "group2", "THEGROUP") + ) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.OK, response.status) + } + + + @Test + fun helloGroup_withMissingNAVIdentRequiredForAuditLog_ShouldGive_401_andHelloGroupCounterIsNOTIncreased() = + testApplication { + application { + module() + } + environment { + config = doConfig() + } + + val response = client.get("/hello_group") { + val jwt = server.issueToken( + issuerId = ISSUER_ID, + subject = "testuser", + claims = mapOf( + "groups" to listOf("group1", "group2", "THEGROUP") + ) + ) + header(AUTHORIZATION_HEADER, "Bearer ${jwt.serialize()}") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + private fun doConfig(acceptedIssuer: String = ISSUER_ID, acceptedAudience: String = ACCEPTED_AUDIENCE): MapApplicationConfig { + return MapApplicationConfig().apply { + put("no.nav.security.jwt.expirythreshold", "5") + put("no.nav.security.jwt.issuers.size", "1") + put("no.nav.security.jwt.issuers.0.issuer_name", acceptedIssuer) + put( + "no.nav.security.jwt.issuers.0.discoveryurl", + server.wellKnownUrl(ISSUER_ID).toString() + )//server.baseUrl() + "/.well-known/openid-configuration") + put("no.nav.security.jwt.issuers.0.accepted_audience", acceptedAudience) + } + } +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/InlineConfigTest.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/InlineConfigTest.kt new file mode 100644 index 00000000..dc71ac35 --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/InlineConfigTest.kt @@ -0,0 +1,152 @@ +package no.nav.security.token.support.v3 + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.any +import com.github.tomakehurst.wiremock.client.WireMock.configureFor +import com.github.tomakehurst.wiremock.client.WireMock.okJson +import com.github.tomakehurst.wiremock.client.WireMock.stubFor +import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import io.ktor.client.request.* +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.Unauthorized +import io.ktor.server.testing.* +import java.util.* +import no.nav.security.token.support.core.JwtTokenConstants.AUTHORIZATION_HEADER +import no.nav.security.token.support.v3.JwkGenerator.jWKSet +import no.nav.security.token.support.v3.JwtTokenGenerator.ACR +import no.nav.security.token.support.v3.JwtTokenGenerator.AUD +import no.nav.security.token.support.v3.JwtTokenGenerator.EXPIRY +import no.nav.security.token.support.v3.JwtTokenGenerator.ISS +import no.nav.security.token.support.v3.JwtTokenGenerator.createSignedJWT +import no.nav.security.token.support.v3.inlineconfigtestapp.helloCounter +import no.nav.security.token.support.v3.inlineconfigtestapp.inlineConfiguredModule +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.slf4j.LoggerFactory +import kotlin.test.assertEquals + +@Disabled("Skjønner ikke hvorfor den kjører lokalt, men ikke i GHA") +class InlineConfigTest { + + companion object { + private val logger = LoggerFactory.getLogger(ApplicationTest::class.java) + val server = WireMockServer(33445) + @BeforeAll + @JvmStatic + fun before() { + server.start() + configureFor(server.port()) + } + @AfterAll + @JvmStatic + fun after() { + server.stop() + } + private fun SignedJWT.asBearer() = "Bearer ${serialize()}" + } + + @Test + fun inlineconfig_withJWTWithUnknownIssuerShouldGive_401_Unauthorized_andHelloCounterIsNOTIncreased() { + val helloCounterBeforeRequest = helloCounter + testApplication{ + stubOIDCProvider() + application { + inlineConfiguredModule() + } + val response = client.get("/inlineconfig") { + header(AUTHORIZATION_HEADER, createSignedJWT(buildClaimSet("testuser", "someUnknownISsuer")).asBearer()) + } + assertEquals(Unauthorized, response.status) + assertEquals(helloCounterBeforeRequest, helloCounter) + } + } + + @Test + fun inlineconfig_withoutValidJWTinHeaderShouldGive_401_andHelloCounterIsNotIncreased() { + val helloCounterBeforeRequest = helloCounter + testApplication{ + application { + stubOIDCProvider() + inlineConfiguredModule() + } + assertEquals(Unauthorized, client.get("/inlineconfig").status) + assertEquals(helloCounterBeforeRequest, helloCounter) + } + } + + @Test + fun inlineconfig_withValidJWTinHeaderShouldGive_200_OK_andHelloCounterIsIncreased() { + val helloCounterBeforeRequest = helloCounter + testApplication { + application { + stubOIDCProvider() + inlineConfiguredModule() + } + val response = client.get("/inlineconfig") { + header(AUTHORIZATION_HEADER, createSignedJWT("testuser").asBearer()) + } + assertEquals(OK, response.status) + assertEquals(helloCounterBeforeRequest + 1, helloCounter) + } + } + + @Test + fun inlineconfig_JWTwithAnotherValidAudienceShouldGive_200_OK_andHelloCounterIsIncreased() { + val helloCounterBeforeRequest = helloCounter + testApplication { + application { + stubOIDCProvider() + inlineConfiguredModule() + } + val response = client.get("/inlineconfig") { + header(AUTHORIZATION_HEADER, createSignedJWT(buildClaimSet(subject = "testuser", audience = "anotherAudience")).asBearer()) + } + assertEquals(OK, response.status) + assertEquals(helloCounterBeforeRequest + 1, helloCounter) + } + } + + @Test + fun inlineconfig_JWTwithUnknownAudienceShouldGive_401_andHelloCounterIsNotIncreased() { + val helloCounterBeforeRequest = helloCounter + testApplication { + application { + stubOIDCProvider() + inlineConfiguredModule() + } + val response = client.get("/inlineconfig") { + header(AUTHORIZATION_HEADER, createSignedJWT(buildClaimSet(subject = "testuser", audience = "unknownAudience")).asBearer()) + } + assertEquals(Unauthorized, response.status) + assertEquals(helloCounterBeforeRequest, helloCounter) + } + } + + fun stubOIDCProvider() { + stubFor(any(urlPathEqualTo("/.well-known/openid-configuration")).willReturn(okJson("""{"jwks_uri": "${server.baseUrl()}/keys", "subject_types_supported": ["pairwise"], "issuer": "$ISS"}"""))) + stubFor(any(urlPathEqualTo("/keys")).willReturn(okJson(jWKSet.toPublicJWKSet().toString()))) + } + + fun buildClaimSet(subject: String, issuer: String = ISS, audience: String = AUD, authLevel: String = ACR, expiry: Long = EXPIRY, issuedAt: Date = Date(), navIdent: String? = null): JWTClaimsSet { + val builder = JWTClaimsSet.Builder() + .subject(subject) + .issuer(issuer) + .audience(audience) + .jwtID(UUID.randomUUID().toString()) + .claim("acr", authLevel) + .claim("ver", "1.0") + .claim("nonce", "myNonce") + .claim("auth_time", issuedAt) + .notBeforeTime(issuedAt) + .issueTime(issuedAt) + .expirationTime(Date(issuedAt.time + expiry)) + if (navIdent != null) { + builder.claim("NAVident", navIdent) + } + return builder.build() + } +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwkGenerator.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwkGenerator.kt new file mode 100644 index 00000000..f651996c --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwkGenerator.kt @@ -0,0 +1,28 @@ +package no.nav.security.token.support.v3 + +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.util.IOUtils +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.text.ParseException + + +object JwkGenerator { + const val DEFAULT_KEYID = "localhost-signer" + const val DEFAULT_JWKSET_FILE = "/jwkset.json" + val defaultRSAKey: RSAKey + get() = jWKSet.getKeyByKeyId(DEFAULT_KEYID) as RSAKey + + val jWKSet: JWKSet + get() = try { + JWKSet.parse( + IOUtils.readInputStreamToString( + JwkGenerator::class.java.getResourceAsStream(DEFAULT_JWKSET_FILE), StandardCharsets.UTF_8)) + } catch (io: IOException) { + throw RuntimeException(io) + } catch (io: ParseException) { + throw RuntimeException(io) + } + +} diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwtTokenGenerator.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwtTokenGenerator.kt new file mode 100644 index 00000000..35c47f8c --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/JwtTokenGenerator.kt @@ -0,0 +1,67 @@ +package no.nav.security.token.support.v3 + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.JWTClaimsSet.Builder +import com.nimbusds.jwt.SignedJWT +import java.util.* +import java.util.concurrent.TimeUnit.MINUTES + +object JwtTokenGenerator { + const val ISS = "iss-localhost" + const val AUD = "aud-localhost" + const val ACR = "Level4" + const val EXPIRY = (60 * 60 * 3600).toLong() + fun signedJWTAsString(subject: String?): String { + return createSignedJWT(subject).serialize() + } + + @JvmOverloads + fun createSignedJWT(subject: String?, expiryInMinutes: Long = EXPIRY): SignedJWT { + val claimsSet = buildClaimSet(subject, ISS, AUD, ACR, MINUTES.toMillis(expiryInMinutes)) + return createSignedJWT( + JwkGenerator.defaultRSAKey, + claimsSet) + } + + fun createSignedJWT(claimsSet: JWTClaimsSet?): SignedJWT { + return createSignedJWT(JwkGenerator.defaultRSAKey, claimsSet) + } + + fun buildClaimSet(subject: String?, issuer: String?, audience: String?, authLevel: String?, + expiry: Long): JWTClaimsSet { + val now = Date() + return Builder() + .subject(subject) + .issuer(issuer) + .audience(audience) + .jwtID(UUID.randomUUID().toString()) + .claim("acr", authLevel) + .claim("ver", "1.0") + .claim("nonce", "myNonce") + .claim("auth_time", now) + .notBeforeTime(now) + .issueTime(now) + .expirationTime(Date(now.time + expiry)).build() + } + + fun createSignedJWT(rsaJwk: RSAKey, claimsSet: JWTClaimsSet?): SignedJWT { + return try { + val header = JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(rsaJwk.keyID) + .type(JOSEObjectType.JWT) + val signedJWT = SignedJWT(header.build(), claimsSet) + val signer: JWSSigner = RSASSASigner(rsaJwk.toPrivateKey()) + signedJWT.sign(signer) + signedJWT + } catch (e: JOSEException) { + throw RuntimeException(e) + } + } +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProviderKtTest.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProviderKtTest.kt new file mode 100644 index 00000000..2f275927 --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/TokenSupportAuthenticationProviderKtTest.kt @@ -0,0 +1,37 @@ +package no.nav.security.token.support.v3 + +import com.nimbusds.jwt.JWTClaimNames.SUBJECT +import io.kotest.assertions.asClue +import io.kotest.matchers.shouldBe +import io.ktor.server.config.* +import no.nav.security.mock.oauth2.withMockOAuth2Server +import no.nav.security.token.support.core.configuration.IssuerProperties +import org.junit.jupiter.api.Test + +internal class TokenSupportAuthenticationProviderKtTest { + + @Test + fun `config properties are parsed correctly`() { + withMockOAuth2Server { + val config = MapApplicationConfig( + "no.nav.security.jwt.expirythreshold" to "5", + "no.nav.security.jwt.issuers.size" to "1", + "no.nav.security.jwt.issuers.0.issuer_name" to "da issuah", + "no.nav.security.jwt.issuers.0.discoveryurl" to this.wellKnownUrl("whatever").toString(), + "no.nav.security.jwt.issuers.0.accepted_audience" to "da audienze", + "no.nav.security.jwt.issuers.0.jwks_cache.lifespan" to "20", + "no.nav.security.jwt.issuers.0.jwks_cache.refreshtime" to "57", + "no.nav.security.jwt.issuers.0.validation.optional_claims" to SUBJECT + ) + + config.asIssuerProps().asClue { + it["da issuah"]?.acceptedAudience shouldBe listOf("da audienze") + it["da issuah"]?.discoveryUrl shouldBe this.wellKnownUrl("whatever").toUrl() + it["da issuah"]?.jwksCache shouldBe IssuerProperties.JwksCache(20, 57) + it["da issuah"]?.validation shouldBe IssuerProperties.Validation(listOf(SUBJECT)) + } + } + } + + +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/inlineconfigtestapp/InlineConfigApplication.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/inlineconfigtestapp/InlineConfigApplication.kt new file mode 100644 index 00000000..f1eeb6e3 --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/inlineconfigtestapp/InlineConfigApplication.kt @@ -0,0 +1,35 @@ +package no.nav.security.token.support.v3.inlineconfigtestapp + +import com.nimbusds.jose.util.DefaultResourceRetriever +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import no.nav.security.token.support.core.configuration.ProxyAwareResourceRetriever.Companion.DEFAULT_HTTP_CONNECT_TIMEOUT +import no.nav.security.token.support.core.configuration.ProxyAwareResourceRetriever.Companion.DEFAULT_HTTP_READ_TIMEOUT +import no.nav.security.token.support.core.configuration.ProxyAwareResourceRetriever.Companion.DEFAULT_HTTP_SIZE_LIMIT +import no.nav.security.token.support.v3.IssuerConfig +import no.nav.security.token.support.v3.TokenSupportConfig +import no.nav.security.token.support.v3.tokenValidationSupport + +fun main(args: Array): Unit = EngineMain.main(args) + +var helloCounter = 0 + +fun Application.inlineConfiguredModule() { + install(Authentication) { + tokenValidationSupport(config = TokenSupportConfig(IssuerConfig("iss-localhost", "http://localhost:33445/.well-known/openid-configuration", listOf("aud-localhost", "anotherAudience"))), resourceRetriever = DefaultResourceRetriever(DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT, DEFAULT_HTTP_SIZE_LIMIT)) + } + routing { + authenticate { + get("/inlineconfig") { + helloCounter++ + call.respondText("Authenticated hello with inline config", ContentType.Text.Html) + } + } + } + + +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/testapp/TestApplication.kt b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/testapp/TestApplication.kt new file mode 100644 index 00000000..c373634c --- /dev/null +++ b/token-validation-ktor-v3/src/test/kotlin/no/nav/security/token/support/v3/testapp/TestApplication.kt @@ -0,0 +1,65 @@ +package no.nav.security.token.support.v3.testapp + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import no.nav.security.token.support.v3.RequiredClaims +import no.nav.security.token.support.v3.TokenValidationContextPrincipal +import no.nav.security.token.support.v3.tokenValidationSupport + +fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) + +@Suppress("unused") // Referenced in application.conf +fun Application.module() { + + val config = this.environment.config + val acceptedIssuer = "default" + + install(Authentication) { + tokenValidationSupport("validToken", config = config) + tokenValidationSupport("validUser", config = config, + requiredClaims = RequiredClaims(issuer = acceptedIssuer, claimMap = arrayOf("NAVident=X112233")) + ) + tokenValidationSupport("validGroup", config = config, + additionalValidation = { + val claims = it.getClaims(acceptedIssuer) + val groups = claims.getAsList("groups") + val hasGroup = groups != null && groups.contains("THEGROUP") + val hasIdentRequiredForAuditLog = claims.getStringClaim("NAVident") != null + hasGroup && hasIdentRequiredForAuditLog + }) + } + + routing { + authenticate("validToken") { + get("/hello") { + + call.respondText("Authenticated hello", ContentType.Text.Html) + } + } + + authenticate("validUser") { + get("/hello_person") { + call.respondText("Hello X112233", ContentType.Text.Html) + } + } + + authenticate("validGroup") { + get("/hello_group") { + val principal: TokenValidationContextPrincipal? = call.authentication.principal() + val ident = principal?.context?.getClaims(acceptedIssuer)?.getStringClaim("NAVident") + println("NAVident = $ident is accessing hello_group") + call.respondText("Hello THEGROUP", ContentType.Text.Html) + } + } + + get("/openhello") { + call.respondText("Hello in the open", ContentType.Text.Html) + } + + } + + +} \ No newline at end of file diff --git a/token-validation-ktor-v3/src/test/resources/jwkset.json b/token-validation-ktor-v3/src/test/resources/jwkset.json new file mode 100644 index 00000000..08c4eca9 --- /dev/null +++ b/token-validation-ktor-v3/src/test/resources/jwkset.json @@ -0,0 +1,13 @@ +{ + "keys": [ + { + "kty": "RSA", + "d": "MRf73iiXUEhJFxDTtJ5rEHNQsAG8XFuXkz9vXXbMp1_OTo11bEx3SnHiwmO_mSAAeXWNJniLw07V1-nk551h5in_ueAPwXTOf8qddacvDEBZwcxeqfu_Kjh1R0ji8Xn1a037CpH2IO34Lyw2gmsGFdMZgDwa5Z0KJjPCU6W8tF6CA-2omAdNzrFaWtaPFpBC0NzYaaB111bKIXxngG97Cnu81deEEKmX-vL-O4tpvUUybuquxrlFvVlTeYlrQqv50_IKsKSYkg-iu1cbqIiWrRq9eTmA6EppmZbqHjKSM5JYFbPB_oZ9QeHKnp1_MTom-jKMEpw18qq-PzdX_skZWQ", + "e": "AQAB", + "use": "sig", + "kid": "localhost-signer", + "alg": "RS256", + "n": "lFTMP9TSUwLua0G8M7foqmdUS2us1-JOF8H_tClVG3IEQMRvMmHJoGSdldWDHsNwRG3Wevl_8fZoGocw9hPqj93j-vI4-ZkbxwhPyRqlS0FNIPD1Ln5R6AmHu7b-paRIz3lvqpyTRwnGBI9weE4u6WOpOQ8DjJMNPq4WcM42AgDJAvc6UuhcWW_MLIsjkKp_VYKxzthSuiRAxXi8Pz4ZhiTAEZI-UN61DYU9YEFNujg5XtIQsRwQn1Vj7BknGwkdf_iCGJgDlKUOz9hAojOMXTAwetUx6I5nngIM5vaXWJCmKn6SzcTYgHWWVrn8qaSazioaydLaYN9NuQ0MdIvsQw" + } + ] +} \ No newline at end of file