Skip to content

Commit

Permalink
klient for den kule STS-tjenesten Gandalf
Browse files Browse the repository at this point in the history
  • Loading branch information
davidsteinsland committed Jan 12, 2024
1 parent 63b3ace commit f995bf8
Show file tree
Hide file tree
Showing 14 changed files with 300 additions and 5 deletions.
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion azure-token-client-default/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
val jacksonVersion = "2.16.1"

dependencies {
api(project(":azure-token-client"))
Expand Down
4 changes: 2 additions & 2 deletions azure-token-client/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
val jacksonVersion = "2.16.1"
val mockkVersion = "1.13.9"
val jacksonVersion: String by project
val mockkVersion: String by project

dependencies {
api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
Expand Down
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
val gradleversjon = "8.5"
val junitJupiterVersion = "5.10.1"
val mockkVersion = "1.13.9"
val jacksonVersion = "2.16.1"

plugins {
kotlin("jvm") version "1.9.22" apply false
Expand All @@ -16,6 +18,9 @@ subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.gradle.maven-publish")

ext.set("jacksonVersion", jacksonVersion)
ext.set("mockkVersion", mockkVersion)

val testImplementation by configurations
val testRuntimeOnly by configurations
dependencies {
Expand Down
7 changes: 7 additions & 0 deletions minimal-sts-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Minimal STS Client
==================

Kontakter STS-tjenesten [Gandalf](https://github.com/navikt/gandalf), som er et bedre alternativ til den tradisjonelle
SOAP-STS-tjenesten.

Med denne klienten er du på god veg til å kvitte deg med CXF og sånt tull.
10 changes: 10 additions & 0 deletions minimal-sts-client/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
val jacksonVersion: String by project
val mockkVersion: String by project

dependencies {
api("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
api("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")

testImplementation("io.mockk:mockk:$mockkVersion")
testImplementation(project(":mock-http-client"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.github.navikt.tbd_libs.soap

import java.util.concurrent.ConcurrentHashMap

class InMemoryStsClient(private val other: SamlTokenProvider) : SamlTokenProvider by (other) {
private val cache = ConcurrentHashMap<String, SamlToken>()

override fun samlToken(username: String, password: String): SamlToken {
return retrieveFromCache(username) ?: storeInCache(username, password)
}

private fun retrieveFromCache(username: String) =
cache[username]?.takeUnless(SamlToken::isExpired)

private fun storeInCache(username: String, password: String): SamlToken {
return other.samlToken(username, password).also {
cache[username] = it
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.github.navikt.tbd_libs.soap

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.Base64

class MinimalStsClient(
private val baseUrl: URI,
private val httpClient: HttpClient = HttpClient.newHttpClient(),
private val objectMapper: ObjectMapper = jacksonObjectMapper()
) : SamlTokenProvider {
override fun samlToken(username: String, password: String): SamlToken {
val body = requestSamlToken(username, password)
return decodeSamlTokenResponse(body)
}

private fun requestSamlToken(username: String, password: String): String {
val encodedCredentials = Base64.getEncoder()
.encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8))
val request = HttpRequest.newBuilder(URI("$baseUrl/rest/v1/sts/samltoken"))
.header("Authorization", "Basic $encodedCredentials")
.GET()
.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
return response.body() ?: throw StsClientException("Tom responskropp fra STS")
}

private fun decodeSamlTokenResponse(body: String): SamlToken {
val node = try {
objectMapper.readTree(body)
} catch (err: Exception) {
throw StsClientException("Kunne ikke tolke JSON fra responsen til STS: $body")
}
return extractSamlTokenFromResponse(node) ?: handleErrorResponse(node)
}

private fun extractSamlTokenFromResponse(node: JsonNode): SamlToken? {
val accessToken = node.path("access_token").takeIf(JsonNode::isTextual)?.asText() ?: return null
val issuedTokenType = node.path("issued_token_type").takeIf(JsonNode::isTextual)?.asText() ?: return null
val expiresIn = node.path("expires_in").takeIf(JsonNode::isNumber)?.asLong() ?: return null
if (issuedTokenType != "urn:ietf:params:oauth:token-type:saml2") throw StsClientException("Ukjent token type: $issuedTokenType")
try {
return SamlToken(
Base64.getDecoder().decode(accessToken).decodeToString(),
LocalDateTime.now().plusSeconds(expiresIn)
)
} catch (err: Exception) {
throw StsClientException("Kunne ikke dekode Base64: ${err.message}", err)
}
}

private fun handleErrorResponse(node: JsonNode): Nothing {
val title = node.path("title").asText()
val errorDetail = node.path("detail").takeIf(JsonNode::isTextual) ?: throw StsClientException("Ukjent respons fra STS: $node")
throw StsClientException("Feil fra STS: $title - $errorDetail")
}
}

class StsClientException(override val message: String, override val cause: Throwable? = null) : RuntimeException()
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.github.navikt.tbd_libs.soap

import java.time.Duration
import java.time.LocalDateTime

class SamlToken(
val token: String,
val expirationTime: LocalDateTime
) {
private companion object {
private val EXPIRATION_MARGIN = Duration.ofSeconds(10)
}
val isExpired get() = expirationTime <= LocalDateTime.now().plus(EXPIRATION_MARGIN)
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.github.navikt.tbd_libs.soap

interface SamlTokenProvider {
fun samlToken(username: String, password: String): SamlToken
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.github.navikt.tbd_libs.soap

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.time.LocalDateTime

class InMemoryStsClientTest {
private companion object {
private const val USERNAME = "foo"
private const val PASSWORD = "bar"
}

@Test
fun `henter fra kilde om cache er tom`() {
val kilde = mockk<SamlTokenProvider>(relaxed = true)
val token = SamlToken("<samltoken>", LocalDateTime.MAX)
every { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) } returns token

val cachedClient = InMemoryStsClient(kilde)
val result = cachedClient.samlToken(USERNAME, PASSWORD)

assertSame(token, result)
verify(exactly = 1) { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) }
}

@Test
fun `henter fra cache om verdi ikke er utgått`() {
val kilde = mockk<SamlTokenProvider>(relaxed = true)
val token = SamlToken("<samltoken>", LocalDateTime.MAX)
every { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) } returns token

val cachedClient = InMemoryStsClient(kilde)
val result1 = cachedClient.samlToken(USERNAME, PASSWORD) // første kall
val result2 = cachedClient.samlToken(USERNAME, PASSWORD) // andre kall

assertSame(token, result1)
assertSame(result1, result2)
verify(exactly = 1) { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) }
}

@Test
fun `henter fra kilde om verdi er utgått`() {
val kilde = mockk<SamlTokenProvider>(relaxed = true)
val token1 = SamlToken("<samltoken>", LocalDateTime.MIN)
val token2 = SamlToken("<nytt samltoken>", LocalDateTime.MIN)
every { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) } returns token1 andThen token2

val cachedClient = InMemoryStsClient(kilde)
val result1 = cachedClient.samlToken(USERNAME, PASSWORD) // første kall
val result2 = cachedClient.samlToken(USERNAME, PASSWORD) // andre kall, oppfrisker

assertSame(token1, result1)
assertNotSame(result1, result2)
assertSame(result2, result2)
verify(exactly = 2) { kilde.samlToken(eq(USERNAME), eq(PASSWORD)) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package com.github.navikt.tbd_libs.soap

import com.github.navikt.tbd_libs.mock.MockHttpResponse
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import org.intellij.lang.annotations.Language
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.nio.charset.StandardCharsets
import java.util.Base64
import kotlin.jvm.optionals.getOrNull

class MinimalStsClientTest {
private companion object {
private const val USERNAME = "username"
private const val PASSWORD = "password"
private const val TOKEN = "<saml token>"

private fun String.encodeBase64() = Base64.getEncoder().encodeToString(toByteArray(StandardCharsets.UTF_8))
}
@Test
fun `dekoder token fra base64`() {
val base64Token = TOKEN.encodeBase64()
val (httpClient, stsClient) = mockClient(tokenResponse(base64Token))

val result = stsClient.samlToken(USERNAME, PASSWORD)

assertEquals(TOKEN, result.token)
verifiserRequest(httpClient) {
it.uri() == URI("http://localhost/rest/v1/sts/samltoken")
&& it.headers().firstValue("Authorization").getOrNull() == "Basic ${"$USERNAME:$PASSWORD".encodeBase64()}"
}
}

@Test
fun `håndterer at token ikke er saml`() {
val base64Token = TOKEN.encodeBase64()
val (_, stsClient) = mockClient(tokenResponse(base64Token, "jwt"))
assertThrows<StsClientException> { stsClient.samlToken(USERNAME, PASSWORD) }
}

@Test
fun `håndterer at token ikke er base 64`() {
val (_, stsClient) = mockClient(tokenResponse(TOKEN))
assertThrows<StsClientException> { stsClient.samlToken(USERNAME, PASSWORD) }
}

@Test
fun `håndterer feil fra server`() {
val (_, stsClient) = mockClient(errorResponse())
assertThrows<StsClientException> { stsClient.samlToken(USERNAME, PASSWORD) }
}

@Test
fun `håndterer ugyldig json i response`() {
val (_, stsClient) = mockClient("Internal Server Error")
assertThrows<StsClientException> { stsClient.samlToken(USERNAME, PASSWORD) }
}

private fun verifiserRequest(httpClient: HttpClient, sjekk: (HttpRequest) -> Boolean) {
verify {
httpClient.send<String>(match {
sjekk(it)
}, any())
}
}

private fun mockClient(response: String): Pair<HttpClient, MinimalStsClient> {
val httpClient = mockk<HttpClient> {
every {
send<String>(any(), any())
} returns MockHttpResponse(response)
}
val stsClient = MinimalStsClient(URI("http://localhost"), httpClient)
return httpClient to stsClient
}

private fun tokenResponse(token: String, tokenType: String = "urn:ietf:params:oauth:token-type:saml2"): String {
@Language("JSON")
val response = """{
"access_token": "$token",
"issued_token_type": "$tokenType",
"token_type": "Bearer",
"expires_in": 3600
}"""
return response
}

private fun errorResponse(): String {
@Language("JSON")
val response = """{
"type": "about:blank",
"title": "Method Not Allowed",
"status": 405,
"detail": "Method 'POST' is not supported.",
"instance": "/rest/v1/sts/samltoken"
}"""
return response
}
}
2 changes: 1 addition & 1 deletion mock-http-client/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
val mockkVersion = "1.13.9"
val mockkVersion: String by project

dependencies {
testImplementation("io.mockk:mockk:$mockkVersion")
Expand Down
2 changes: 1 addition & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
rootProject.name = "tbd-libs"
include("azure-token-client", "azure-token-client-default", "mock-http-client")
include("azure-token-client", "azure-token-client-default", "mock-http-client", "minimal-sts-client")

0 comments on commit f995bf8

Please sign in to comment.