Skip to content

Commit 7de4411

Browse files
authored
add support for configuration of standalone server/docker image (navikt#34)
**feat: add `RequestMappingTokenCallback`** * tokenCallback implementation which can return claims based on token request parameter matching * make `OAuth2TokenCallback.subject()` nullable to allow for tokens without sub claim **feat: configure `StandaloneMockOAuth2Server.kt` from env and json** * support loading `OAuth2Config` from env var or json file * `httpServer` (type) can be specified from json config * `tokenCallbacks` can be specified from json config using the `RequestMappingTokenCallback` feature
1 parent 35a7869 commit 7de4411

13 files changed

+448
-28
lines changed

build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import java.time.Duration
22
import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask
3+
34
val assertjVersion = "3.19.0"
45
val kotlinLoggingVersion = "2.0.4"
56
val logbackVersion = "1.2.3"
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,46 @@
11
package no.nav.security.mock.oauth2
22

3+
import com.fasterxml.jackson.core.JsonParser
4+
import com.fasterxml.jackson.core.type.TypeReference
5+
import com.fasterxml.jackson.databind.DeserializationContext
6+
import com.fasterxml.jackson.databind.JsonDeserializer
7+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
8+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
9+
import com.fasterxml.jackson.module.kotlin.readValue
310
import no.nav.security.mock.oauth2.http.MockWebServerWrapper
11+
import no.nav.security.mock.oauth2.http.NettyWrapper
412
import no.nav.security.mock.oauth2.http.OAuth2HttpServer
513
import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
614
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
15+
import no.nav.security.mock.oauth2.token.RequestMappingTokenCallback
716

817
data class OAuth2Config @JvmOverloads constructor(
918
val interactiveLogin: Boolean = false,
1019
val tokenProvider: OAuth2TokenProvider = OAuth2TokenProvider(),
20+
@JsonDeserialize(contentAs = RequestMappingTokenCallback::class)
1121
val tokenCallbacks: Set<OAuth2TokenCallback> = emptySet(),
22+
@JsonDeserialize(using = OAuth2HttpServerDeserializer::class)
1223
val httpServer: OAuth2HttpServer = MockWebServerWrapper()
13-
)
24+
) {
25+
26+
class OAuth2HttpServerDeserializer : JsonDeserializer<OAuth2HttpServer>() {
27+
enum class ServerType {
28+
MockWebServerWrapper,
29+
NettyWrapper
30+
}
31+
32+
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OAuth2HttpServer {
33+
return when (p.readValueAs<ServerType>(object : TypeReference<ServerType>() {})) {
34+
ServerType.NettyWrapper -> NettyWrapper()
35+
ServerType.MockWebServerWrapper -> MockWebServerWrapper()
36+
else -> throw IllegalArgumentException("unsupported httpServer specified in config")
37+
}
38+
}
39+
}
40+
41+
companion object {
42+
fun fromJson(json: String): OAuth2Config {
43+
return jacksonObjectMapper().readValue(json)
44+
}
45+
}
46+
}
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,58 @@
11
package no.nav.security.mock.oauth2
22

3-
import java.net.InetAddress
4-
import java.net.InetSocketAddress
3+
import no.nav.security.mock.oauth2.StandaloneConfig.hostname
4+
import no.nav.security.mock.oauth2.StandaloneConfig.oauth2Config
5+
import no.nav.security.mock.oauth2.StandaloneConfig.port
56
import no.nav.security.mock.oauth2.http.NettyWrapper
67
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
78
import no.nav.security.mock.oauth2.http.route
9+
import java.io.File
10+
import java.io.FileNotFoundException
11+
import java.net.InetAddress
12+
import java.net.InetSocketAddress
13+
14+
object StandaloneConfig {
15+
const val JSON_CONFIG = "JSON_CONFIG"
16+
const val JSON_CONFIG_PATH = "JSON_CONFIG_PATH"
17+
const val SERVER_HOSTNAME = "SERVER_HOSTNAME"
18+
const val SERVER_PORT = "SERVER_PORT"
19+
20+
fun hostname(): InetAddress = SERVER_HOSTNAME.fromEnv()
21+
?.let { InetAddress.getByName(it) } ?: InetSocketAddress(0).address
822

9-
data class Configuration(
10-
val server: Server = Server()
11-
) {
12-
data class Server(
13-
val hostname: InetAddress = "SERVER_HOSTNAME".fromEnv()?.let { InetAddress.getByName(it) } ?: InetSocketAddress(0).address,
14-
val port: Int = "SERVER_PORT".fromEnv()?.toInt() ?: 8080
15-
)
23+
fun port(): Int = SERVER_PORT.fromEnv()?.toInt() ?: 8080
24+
25+
fun oauth2Config(): OAuth2Config = with(jsonFromEnv()) {
26+
if (this != null) {
27+
OAuth2Config.fromJson(this)
28+
} else {
29+
OAuth2Config(
30+
interactiveLogin = true,
31+
httpServer = NettyWrapper()
32+
)
33+
}
34+
}
35+
36+
private fun jsonFromEnv() = JSON_CONFIG.fromEnv() ?: JSON_CONFIG_PATH.fromEnv("config.json").readFile()
37+
38+
private fun String.readFile(): String? =
39+
try {
40+
File(this).readText()
41+
} catch (e: FileNotFoundException) {
42+
null
43+
}
1644
}
1745

1846
fun main() {
19-
val config = Configuration()
2047
MockOAuth2Server(
21-
OAuth2Config(
22-
interactiveLogin = true,
23-
httpServer = NettyWrapper()
24-
),
48+
oauth2Config(),
2549
route("/isalive") {
2650
OAuth2HttpResponse(status = 200, body = "alive and well")
2751
}
28-
).start(config.server.hostname, config.server.port)
52+
).apply {
53+
start(hostname(), port())
54+
}
2955
}
3056

57+
fun String.fromEnv(default: String): String = System.getenv(this) ?: default
3158
fun String.fromEnv(): String? = System.getenv(this)

src/main/kotlin/no/nav/security/mock/oauth2/extensions/NimbusExtensions.kt

+9
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ import com.nimbusds.oauth2.sdk.auth.ClientAuthentication
2222
import com.nimbusds.oauth2.sdk.auth.PrivateKeyJWT
2323
import com.nimbusds.oauth2.sdk.id.Issuer
2424
import com.nimbusds.openid.connect.sdk.AuthenticationRequest
25+
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
2526
import com.nimbusds.openid.connect.sdk.Prompt
2627
import java.time.Duration
2728
import java.time.Instant
2829
import java.util.HashSet
2930
import no.nav.security.mock.oauth2.OAuth2Exception
31+
import no.nav.security.mock.oauth2.grant.TokenExchangeGrant
3032
import no.nav.security.mock.oauth2.invalidRequest
3133

3234
fun AuthenticationRequest.isPrompt(): Boolean =
@@ -38,6 +40,13 @@ fun TokenRequest.grantType(): GrantType =
3840
this.authorizationGrant?.type
3941
?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "missing required parameter grant_type")
4042

43+
fun TokenRequest.scopesWithoutOidcScopes() =
44+
scope?.toStringList()?.filterNot { value ->
45+
OIDCScopeValue.values().map { it.toString() }.contains(value)
46+
} ?: emptyList()
47+
48+
fun TokenRequest.tokenExchangeGrantOrNull(): TokenExchangeGrant? = authorizationGrant as? TokenExchangeGrant
49+
4150
fun TokenRequest.authorizationCode(): AuthorizationCode =
4251
this.authorizationGrant
4352
?.let { it as? AuthorizationCodeGrant }

src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequest.kt

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ data class OAuth2HttpRequest(
4848
val clientAuthentication = ClientAuthentication.parse(httpRequest).requirePrivateKeyJwt(this.url.toString(), 120)
4949
val tokenExchangeGrant = TokenExchangeGrant.parse(formParameters.map)
5050

51+
// TODO: add scope if present in request
5152
return TokenRequest(
5253
this.url.toUri(),
5354
clientAuthentication,

src/main/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenCallback.kt

+47-11
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@ package no.nav.security.mock.oauth2.token
22

33
import com.nimbusds.oauth2.sdk.GrantType
44
import com.nimbusds.oauth2.sdk.TokenRequest
5-
import com.nimbusds.openid.connect.sdk.OIDCScopeValue
6-
import java.util.UUID
75
import no.nav.security.mock.oauth2.extensions.clientIdAsString
86
import no.nav.security.mock.oauth2.extensions.grantType
9-
import no.nav.security.mock.oauth2.grant.TokenExchangeGrant
7+
import no.nav.security.mock.oauth2.extensions.scopesWithoutOidcScopes
8+
import no.nav.security.mock.oauth2.extensions.tokenExchangeGrantOrNull
9+
import java.time.Duration
10+
import java.util.UUID
1011

1112
interface OAuth2TokenCallback {
1213
fun issuerId(): String
13-
fun subject(tokenRequest: TokenRequest): String
14+
fun subject(tokenRequest: TokenRequest): String?
1415
fun audience(tokenRequest: TokenRequest): List<String>
1516
fun addClaims(tokenRequest: TokenRequest): Map<String, Any>
1617
fun tokenExpiry(): Long
@@ -36,13 +37,13 @@ open class DefaultOAuth2TokenCallback(
3637
}
3738

3839
override fun audience(tokenRequest: TokenRequest): List<String> {
39-
val oidcScopeList = OIDCScopeValue.values().map { it.toString() }
40-
return audience
41-
?: (tokenRequest.authorizationGrant as? TokenExchangeGrant)?.audience
42-
?: let {
43-
tokenRequest.scope?.toStringList()
44-
?.filterNot { oidcScopeList.contains(it) }
45-
} ?: listOf("default")
40+
val audienceParam = tokenRequest.tokenExchangeGrantOrNull()?.audience
41+
return when {
42+
audience != null -> audience
43+
audienceParam != null -> audienceParam
44+
tokenRequest.scope != null -> tokenRequest.scopesWithoutOidcScopes()
45+
else -> listOf("default")
46+
}
4647
}
4748

4849
override fun addClaims(tokenRequest: TokenRequest): Map<String, Any> =
@@ -57,3 +58,38 @@ open class DefaultOAuth2TokenCallback(
5758

5859
override fun tokenExpiry(): Long = expiry
5960
}
61+
62+
data class RequestMappingTokenCallback(
63+
val issuerId: String,
64+
val requestMappings: Set<RequestMapping>,
65+
val tokenExpiry: Long = Duration.ofHours(1).toSeconds()
66+
) : OAuth2TokenCallback {
67+
override fun issuerId(): String = issuerId
68+
override fun subject(tokenRequest: TokenRequest): String? =
69+
requestMappings.getClaimOrNull(tokenRequest, "sub")
70+
71+
override fun audience(tokenRequest: TokenRequest): List<String> =
72+
requestMappings.getClaimOrNull(tokenRequest, "aud") ?: emptyList()
73+
74+
override fun addClaims(tokenRequest: TokenRequest): Map<String, Any> =
75+
requestMappings.getClaims(tokenRequest)
76+
77+
override fun tokenExpiry(): Long = tokenExpiry
78+
79+
private fun Set<RequestMapping>.getClaims(tokenRequest: TokenRequest) =
80+
firstOrNull { it.isMatch(tokenRequest) }?.claims ?: emptyMap()
81+
82+
private inline fun <reified T> Set<RequestMapping>.getClaimOrNull(tokenRequest: TokenRequest, key: String): T? =
83+
getClaims(tokenRequest)[key] as? T
84+
}
85+
86+
data class RequestMapping(
87+
private val requestParam: String,
88+
private val match: String = "*",
89+
val claims: Map<String, Any> = emptyMap()
90+
) {
91+
fun isMatch(tokenRequest: TokenRequest): Boolean =
92+
tokenRequest.toHTTPRequest().queryParameters[requestParam]?.any {
93+
if (match != "*") it == match else true
94+
} ?: false
95+
}

src/main/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenProvider.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class OAuth2TokenProvider {
9090

9191
private fun defaultClaims(
9292
issuerUrl: HttpUrl,
93-
subject: String,
93+
subject: String?,
9494
audience: List<String>,
9595
nonce: String?,
9696
additionalClaims: Map<String, Any>,

src/main/resources/logback.xml

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
</layout>
99
</appender>
1010

11+
<logger name="io.netty" level="info" />
12+
1113
<root level="debug">
1214
<appender-ref ref="stdout"/>
1315
</root>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package no.nav.security.mock.oauth2
2+
3+
import com.fasterxml.jackson.databind.exc.InvalidFormatException
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.matchers.collections.shouldContainAll
6+
import io.kotest.matchers.should
7+
import io.kotest.matchers.shouldBe
8+
import io.kotest.matchers.types.beInstanceOf
9+
import no.nav.security.mock.oauth2.FullConfig.configJson
10+
import no.nav.security.mock.oauth2.HttpServerConfig.withMockWebServerWrapper
11+
import no.nav.security.mock.oauth2.HttpServerConfig.withNettyHttpServer
12+
import no.nav.security.mock.oauth2.HttpServerConfig.withUnknownHttpServer
13+
import no.nav.security.mock.oauth2.http.MockWebServerWrapper
14+
import no.nav.security.mock.oauth2.http.NettyWrapper
15+
import org.intellij.lang.annotations.Language
16+
import org.junit.jupiter.api.Test
17+
18+
internal class OAuth2ConfigTest {
19+
20+
@Test
21+
fun `create httpServer from json`() {
22+
OAuth2Config.fromJson(withNettyHttpServer).httpServer should beInstanceOf<NettyWrapper>()
23+
OAuth2Config.fromJson(withMockWebServerWrapper).httpServer should beInstanceOf<MockWebServerWrapper>()
24+
shouldThrow<InvalidFormatException> {
25+
OAuth2Config.fromJson(withUnknownHttpServer)
26+
}
27+
}
28+
29+
@Test
30+
fun `create full config from json with multiple tokenCallbacks`() {
31+
val config = OAuth2Config.fromJson(configJson)
32+
config.interactiveLogin shouldBe true
33+
config.httpServer should beInstanceOf<NettyWrapper>()
34+
config.tokenCallbacks.size shouldBe 2
35+
config.tokenCallbacks.map {
36+
it.issuerId()
37+
}.toList() shouldContainAll listOf("issuer1", "issuer2")
38+
}
39+
}
40+
41+
object FullConfig {
42+
@Language("json")
43+
val configJson = """{
44+
"interactiveLogin" : true,
45+
"httpServer": "NettyWrapper",
46+
"tokenCallbacks": [
47+
{
48+
"issuerId": "issuer1",
49+
"tokenExpiry": 120,
50+
"requestMappings": [
51+
{
52+
"requestParam": "scope",
53+
"match": "scope1",
54+
"claims": {
55+
"sub": "subByScope",
56+
"aud": [
57+
"audByScope"
58+
]
59+
}
60+
}
61+
]
62+
},
63+
{
64+
"issuerId": "issuer2",
65+
"requestMappings": [
66+
{
67+
"requestParam": "someparam",
68+
"match": "somevalue",
69+
"claims": {
70+
"sub": "subBySomeParam",
71+
"aud": [
72+
"audBySomeParam"
73+
]
74+
}
75+
}
76+
]
77+
}
78+
]
79+
}
80+
""".trimIndent()
81+
}
82+
83+
object HttpServerConfig {
84+
@Language("json")
85+
val withMockWebServerWrapper = """
86+
{
87+
"httpServer": "MockWebServerWrapper"
88+
}
89+
""".trimIndent()
90+
91+
@Language("json")
92+
val withNettyHttpServer = """
93+
{
94+
"httpServer": "NettyWrapper"
95+
}
96+
""".trimIndent()
97+
98+
@Language("json")
99+
val withUnknownHttpServer = """
100+
{
101+
"httpServer": "UnknownServer"
102+
}
103+
""".trimIndent()
104+
}

0 commit comments

Comments
 (0)