diff --git a/build.gradle.kts b/build.gradle.kts index 98eb9fa..6d7db9b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,7 @@ subprojects { tasks { withType { - kotlinOptions.jvmTarget = "20" + kotlinOptions.jvmTarget = "21" } withType { duplicatesStrategy = DuplicatesStrategy.INCLUDE diff --git a/ktor-auth-tokenx/build.gradle.kts b/ktor-auth-tokenx/build.gradle.kts new file mode 100644 index 0000000..5937d2a --- /dev/null +++ b/ktor-auth-tokenx/build.gradle.kts @@ -0,0 +1,15 @@ +val ktorVersion = "2.3.6" + +dependencies { +// implementation(project(":cache")) + + implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("io.ktor:ktor-client-auth:$ktorVersion") + + implementation("io.ktor:ktor-serialization-jackson:$ktorVersion") + + implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.3") + implementation("com.nimbusds:nimbus-jose-jwt:9.31") +} diff --git a/ktor-auth-tokenx/main/no/nav/aap/ktor/client/Token.kt b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/Token.kt new file mode 100644 index 0000000..04ee174 --- /dev/null +++ b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/Token.kt @@ -0,0 +1,43 @@ +package no.nav.aap.ktor.client + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.Instant + +internal data class Token(val expires_in: Long, val access_token: String) { + private val expiry: Instant = Instant.now().plusSeconds(expires_in - LEEWAY_SECONDS) + + internal fun expired() = Instant.now().isAfter(expiry) + + private companion object { + const val LEEWAY_SECONDS = 60 + } +} + +internal class TokenCache { + private val tokens: HashMap = hashMapOf() + private val mutex = Mutex() + + internal suspend fun add(key: K, token: Token) { + mutex.withLock { + tokens[key] = token + } + } + + internal suspend fun get(key: K): Token? { + tokens[key]?.let { + if (it.expired()) { + rm(key) + } + } + return mutex.withLock { + tokens[key] + } + } + + private suspend fun rm(key: K) { + mutex.withLock { + tokens.remove(key) + } + } +} diff --git a/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXProviderConfig.kt b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXProviderConfig.kt new file mode 100644 index 0000000..413918d --- /dev/null +++ b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXProviderConfig.kt @@ -0,0 +1,11 @@ +package no.nav.aap.ktor.client + +import java.net.URL + +data class TokenXProviderConfig( + val clientId: String, + val privateKey: String, + val tokenEndpoint: String, + val jwksUrl: URL, + val issuer: String, +) diff --git a/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXTokenProvider.kt b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXTokenProvider.kt new file mode 100644 index 0000000..c4cd01c --- /dev/null +++ b/ktor-auth-tokenx/main/no/nav/aap/ktor/client/TokenXTokenProvider.kt @@ -0,0 +1,94 @@ +package no.nav.aap.ktor.client + +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import io.ktor.serialization.jackson.jackson +import org.slf4j.LoggerFactory +import java.time.Instant +import java.util.* + +private val secureLog = LoggerFactory.getLogger("secureLog") +class TokenXTokenProvider( + private val config: TokenXProviderConfig, + private val audience: String, + private val client: io.ktor.client.HttpClient = defaultHttpClient, +) { + + private val jwtFactory = JwtGrantFactory(config) + suspend fun getOnBehalfOfToken(tokenx_token: String) = getAccessToken(tokenx_token) { + """ + grant_type=urn:ietf:params:oauth:grant-type:token-exchange + &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer + &subject_token_type=urn:ietf:params:oauth:token-type:jwt + &client_assertion=${jwtFactory.jwt} + &audience=$audience + &subject_token=$tokenx_token + """.trimIndent() + .replace("\n", "") + } + + private val cache = TokenCache() + + private suspend fun getAccessToken(cacheKey: String, body: () -> String): String { + val token = cache.get(cacheKey) + ?: client.post(config.tokenEndpoint) { + accept(io.ktor.http.ContentType.Application.Json) + contentType(io.ktor.http.ContentType.Application.FormUrlEncoded) + setBody(body()) + }.also { + if (!it.status.isSuccess()) { + secureLog.warn("Feilet token-kall {}: {}", it.status.value, it.bodyAsText()) + } + }.body().also { + cache.add(cacheKey, it) + } + + return token.access_token + } + + private companion object { + private val defaultHttpClient = io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO) { + install(io.ktor.client.plugins.contentnegotiation.ContentNegotiation) { + jackson { + registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()) + disable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } + } + } + } +} + +internal class JwtGrantFactory(private val config: TokenXProviderConfig) { + internal val jwt: String get() = signedJwt.serialize() + + private val privateKey = RSAKey.parse(config.privateKey) + private val signedJwt get() = SignedJWT(jwsHeader, jwtClaimSet).apply { sign(RSASSASigner(privateKey)) } + private val jwsHeader + get() = JWSHeader.Builder(JWSAlgorithm.RS256) + .keyID(privateKey.keyID) + .type(JOSEObjectType.JWT) + .build() + + private val jwtClaimSet: JWTClaimsSet + get() = JWTClaimsSet.Builder().apply { + subject(config.clientId) + issuer(config.clientId) + audience(config.tokenEndpoint) + jwtID(UUID.randomUUID().toString()) + notBeforeTime(Date()) + issueTime(Date()) + expirationTime(Date.from(Instant.now().plusSeconds(120))) + }.build() +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a5be23b..a76212a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,5 +9,6 @@ include( "kafka-test-2", "ktor-auth-azuread", "ktor-auth-maskinporten", + "ktor-auth-tokenx", "ktor-utils", )