Skip to content

Commit 1e8d271

Browse files
fix(jwt): provide algorithm when verifying signature and issuer (#353)
* fix(jwt): provide algorithm when verifying signature and issuer * test(userinfo): add tests for non-default algorithm * test(introspect): add tests for non-default algorithm * docs: fix minor typos * chore: refactor files, cleanup imports * refactor(tests): remove extra println Co-authored-by: Youssef Bel Mekki <[email protected]>
1 parent 5f69e56 commit 1e8d271

File tree

12 files changed

+132
-13
lines changed

12 files changed

+132
-13
lines changed

CONTRIBUTING.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ Please fork the repo and start a new branch to work on.
55

66
## Building locally
77
This project is using [Gradle](https://gradle.org/) for its build tool.
8-
A Gradle Wrapper is included in the code though so you do not have to manage your own installation.
8+
A Gradle Wrapper is included in the code though, so you do not have to manage your own installation.
99

10-
To run a build simply exucute the following:
10+
To run a build simply execute the following:
1111

1212
```shell script
1313
./gradlew build
@@ -23,7 +23,7 @@ If you are adding a new feature or bug fix please ensure there is proper test co
2323
If you have a branch on your fork that is ready to be merged, please create a new pull request. The maintainers will review to make sure the above guidelines have been followed and if the changes are helpful to all library users, they will be merged.
2424

2525
## Releasing
26-
The release process has been automated in Github Actions. Every merge into master is automatically added to the
26+
The release process has been automated in GitHub Actions. Every merge into master is automatically added to the
2727
[draft release notes](https://github.com/navikt/mock-oauth2-server/releases) of the next version. Once the next
2828
version is ready to be released, simply publish the release with the version name as the title and tag and this
2929
will trigger to publishing process.

README.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Have a look at some examples in both Java and Kotlin in the src/test directory:
194194

195195
##### Server URLs
196196

197-
You can retrieve URLs from the server with the correct port and issuerId etc by invoking one of the ` fun *Url(issuerId: String): HttpUrl` functions/methods:
197+
You can retrieve URLs from the server with the correct port and issuerId etc. by invoking one of the ` fun *Url(issuerId: String): HttpUrl` functions/methods:
198198

199199
```kotlin
200200
val server = MockOAuth2Server()
@@ -293,7 +293,7 @@ add this to your config with preferred `JWS algorithm`:
293293

294294
*From the JSON example above:*
295295

296-
A token request to `http://localhost:8080/issuer1/token` with parameter `scope` equal to `scope1` will match the first tokencallback:
296+
A token request to `http://localhost:8080/issuer1/token` with parameter `scope` equal to `scope1` will match the first `tokenCallback`:
297297

298298
```json
299299
{
@@ -448,7 +448,7 @@ This project is currently maintained by the organisation [@navikt](https://githu
448448

449449
If you need to raise an issue or question about this library, please create an issue here and tag it with the appropriate label.
450450

451-
For contact requests within the [@navikt](https://github.com/navikt) org, you can use the slack channel #pig_sikkerhet
451+
For contact requests within the [@navikt](https://github.com/navikt) org, you can use the Slack channel #pig_sikkerhet
452452

453453
If you need to contact anyone directly, please see contributors.
454454

src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ open class MockOAuth2Server(
4545
vararg additionalRoutes: Route
4646
) {
4747
constructor(vararg additionalRoutes: Route) : this(config = OAuth2Config(), additionalRoutes = additionalRoutes)
48+
constructor(config: OAuth2Config) : this(config = config, additionalRoutes = emptyArray())
4849

4950
private val httpServer = config.httpServer
5051
private val defaultRequestHandler: OAuth2HttpRequestHandler = OAuth2HttpRequestHandler(config)
@@ -304,9 +305,10 @@ internal fun Map<String, Any>.toJwtClaimsSet(): JWTClaimsSet =
304305
}.build()
305306

306307
fun <R> withMockOAuth2Server(
308+
config: OAuth2Config = OAuth2Config(),
307309
test: MockOAuth2Server.() -> R
308310
): R {
309-
val server = MockOAuth2Server()
311+
val server = MockOAuth2Server(config)
310312
server.start()
311313
try {
312314
return server.test()

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

-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ data class OAuth2HttpRequest(
149149
.query(originalUrl.query)
150150
.build()
151151
} ?: originalUrl
152-
153152
}
154153
}
155154

src/main/kotlin/no/nav/security/mock/oauth2/introspect/Introspect.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ private fun OAuth2HttpRequest.verifyToken(tokenProvider: OAuth2TokenProvider): J
5454
val tokenString = this.formParameters.get("token")
5555
val issuer = url.toIssuerUrl()
5656
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
57+
val algorithm = tokenProvider.getAlgorithm()
5758
return try {
58-
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet)
59+
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet, algorithm)
5960
} catch (e: Exception) {
6061
log.debug("token_introspection: failed signature validation")
6162
return null

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ data class KeyGenerator(
8686
}
8787
}
8888
).keyGenerator
89-
} else null
89+
} else {
90+
null
91+
}
9092
}.singleOrNull() ?: throw OAuth2Exception("Unsupported algorithm: $algorithm")
9193
}
9294

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

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ class OAuth2TokenProvider @JvmOverloads constructor(
2727
return JWKSet(keyProvider.signingKey(issuerId)).toPublicJWKSet()
2828
}
2929

30+
fun getAlgorithm(): JWSAlgorithm {
31+
return keyProvider.algorithm()
32+
}
33+
3034
fun idToken(
3135
tokenRequest: TokenRequest,
3236
issuerUrl: HttpUrl,

src/main/kotlin/no/nav/security/mock/oauth2/userinfo/UserInfo.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ private fun OAuth2HttpRequest.verifyBearerToken(tokenProvider: OAuth2TokenProvid
3030
val tokenString = this.headers.bearerToken()
3131
val issuer = url.toIssuerUrl()
3232
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
33+
val algorithm = tokenProvider.getAlgorithm()
3334
return try {
34-
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet)
35+
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet, algorithm)
3536
} catch (e: Exception) {
3637
throw invalidToken(e.message ?: "could not verify bearer token")
3738
}

src/test/kotlin/no/nav/security/mock/oauth2/e2e/TokenExchangeGrantIntegrationTest.kt

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package no.nav.security.mock.oauth2.e2e
22

33
import com.nimbusds.jwt.JWTClaimsSet
4-
import io.kotest.core.spec.style.AnnotationSpec
54
import io.kotest.matchers.collections.shouldContainExactly
65
import io.kotest.matchers.should
76
import io.kotest.matchers.shouldBe

src/test/kotlin/no/nav/security/mock/oauth2/e2e/UserInfoIntegrationTest.kt

+44
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
package no.nav.security.mock.oauth2.e2e
22

3+
import com.nimbusds.jose.JWSAlgorithm
34
import com.nimbusds.jwt.SignedJWT
45
import io.kotest.assertions.asClue
56
import io.kotest.matchers.maps.shouldContainAll
7+
import io.kotest.matchers.shouldBe
8+
import no.nav.security.mock.oauth2.MockOAuth2Server
9+
import no.nav.security.mock.oauth2.OAuth2Config
610
import no.nav.security.mock.oauth2.testutils.claims
711
import no.nav.security.mock.oauth2.testutils.client
812
import no.nav.security.mock.oauth2.testutils.get
913
import no.nav.security.mock.oauth2.testutils.parse
14+
import no.nav.security.mock.oauth2.token.KeyProvider
15+
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1016
import no.nav.security.mock.oauth2.withMockOAuth2Server
1117
import okhttp3.Headers
1218
import org.junit.jupiter.api.Test
1319

1420
class UserInfoIntegrationTest {
1521

1622
private val client = client()
23+
private val rs384Config = OAuth2Config(
24+
tokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(initialKeys = emptyList(), algorithm = JWSAlgorithm.RS384.name))
25+
)
1726

1827
@Test
1928
fun `userinfo should return claims from token when valid bearer token is present`() {
@@ -33,6 +42,41 @@ class UserInfoIntegrationTest {
3342
}
3443
}
3544

45+
@Test
46+
fun `userinfo should return claims from token signed with non-default algorithm when valid bearer token is present`() {
47+
withMockOAuth2Server(config = rs384Config) {
48+
val issuerId = "default"
49+
val token = this.issueToken(issuerId = issuerId, subject = "foo", claims = mapOf("extra" to "bar"))
50+
token.header.algorithm.shouldBe(JWSAlgorithm.RS384)
51+
client.get(
52+
url = this.userInfoUrl(issuerId),
53+
headers = token.asBearerTokenHeader()
54+
).asClue {
55+
it.parse<Map<String, Any>>() shouldContainAll mapOf(
56+
"sub" to token.claims["sub"],
57+
"iss" to token.claims["iss"],
58+
"extra" to token.claims["extra"]
59+
)
60+
}
61+
}
62+
}
63+
64+
@Test
65+
fun `userinfo should return error from token signed with non-default algorithm does not match server config`() {
66+
val issuerId = "default"
67+
val rs384Server = MockOAuth2Server(config = rs384Config)
68+
val token = rs384Server.issueToken(issuerId = issuerId, subject = "foo", claims = mapOf("extra" to "bar"))
69+
withMockOAuth2Server {
70+
client.get(
71+
url = this.userInfoUrl(issuerId),
72+
headers = token.asBearerTokenHeader()
73+
).asClue {
74+
it.code shouldBe 401
75+
it.message shouldBe "Client Error"
76+
}
77+
}
78+
}
79+
3680
private fun SignedJWT.asBearerTokenHeader(): Headers = this.serialize().let {
3781
Headers.headersOf("Authorization", "Bearer $it")
3882
}

src/test/kotlin/no/nav/security/mock/oauth2/introspect/IntrospectTest.kt

+43-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package no.nav.security.mock.oauth2.introspect
22

33
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
44
import com.fasterxml.jackson.module.kotlin.readValue
5+
import com.nimbusds.jose.JWSAlgorithm
56
import io.kotest.assertions.asClue
67
import io.kotest.assertions.throwables.shouldThrow
78
import io.kotest.matchers.maps.shouldContain
@@ -13,12 +14,14 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
1314
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
1415
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
1516
import no.nav.security.mock.oauth2.http.routes
17+
import no.nav.security.mock.oauth2.token.KeyProvider
1618
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1719
import okhttp3.Headers
1820
import okhttp3.HttpUrl.Companion.toHttpUrl
1921
import org.junit.jupiter.api.Test
2022

2123
internal class IntrospectTest {
24+
private val rs384TokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(initialKeys = emptyList(), algorithm = JWSAlgorithm.RS384.name))
2225

2326
@Test
2427
fun `introspect should return active and claims from bearer token`() {
@@ -31,7 +34,6 @@ internal class IntrospectTest {
3134
"sub" to "foo"
3235
)
3336
val token = tokenProvider.jwt(claims)
34-
println("token: " + token.jwtClaimsSet.toJSONObject())
3537
val request = request("$issuerUrl$INTROSPECT", token.serialize())
3638

3739
routes { introspect(tokenProvider) }.invoke(request).asClue {
@@ -42,6 +44,26 @@ internal class IntrospectTest {
4244
}
4345
}
4446

47+
@Test
48+
fun `introspect should return active and claims for non-default algorithm from bearer token`() {
49+
val issuerUrl = "http://localhost/default"
50+
val claims = mapOf(
51+
"iss" to issuerUrl,
52+
"client_id" to "yolo",
53+
"token_type" to "token",
54+
"sub" to "foo"
55+
)
56+
val token = rs384TokenProvider.jwt(claims)
57+
val request = request("$issuerUrl$INTROSPECT", token.serialize())
58+
59+
routes { introspect(rs384TokenProvider) }.invoke(request).asClue {
60+
it.status shouldBe 200
61+
val response = it.parse<Map<String, Any>>()
62+
response shouldContainAll claims
63+
response shouldContain ("active" to true)
64+
}
65+
}
66+
4567
@Test
4668
fun `introspect should return active false when token is missing`() {
4769
val url = "http://localhost/default$INTROSPECT"
@@ -66,6 +88,26 @@ internal class IntrospectTest {
6688
}
6789
}
6890

91+
@Test
92+
fun `introspect should return active false when token was signed with a different algorithm than token provider`() {
93+
val issuerUrl = "http://localhost/default"
94+
val claims = mapOf(
95+
"iss" to issuerUrl,
96+
"client_id" to "yolo",
97+
"token_type" to "token",
98+
"sub" to "foo"
99+
)
100+
val token = rs384TokenProvider.jwt(claims)
101+
val request = request("$issuerUrl$INTROSPECT", token.serialize())
102+
103+
routes {
104+
introspect(OAuth2TokenProvider())
105+
}.invoke(request).asClue {
106+
it.status shouldBe 200
107+
it.parse<Map<String, Any>>() shouldContainExactly mapOf("active" to false)
108+
}
109+
}
110+
69111
@Test
70112
fun `introspect should return 401 when no Authorization header is provided`() {
71113
val url = "http://localhost/default$INTROSPECT"

src/test/kotlin/no/nav/security/mock/oauth2/userinfo/UserInfoTest.kt

+25
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package no.nav.security.mock.oauth2.userinfo
22

33
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
44
import com.fasterxml.jackson.module.kotlin.readValue
5+
import com.nimbusds.jose.JWSAlgorithm
56
import io.kotest.assertions.asClue
67
import io.kotest.assertions.throwables.shouldThrow
78
import io.kotest.matchers.maps.shouldContainAll
@@ -11,6 +12,7 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO
1112
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
1213
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
1314
import no.nav.security.mock.oauth2.http.routes
15+
import no.nav.security.mock.oauth2.token.KeyProvider
1416
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1517
import okhttp3.Headers
1618
import okhttp3.HttpUrl.Companion.toHttpUrl
@@ -36,6 +38,29 @@ internal class UserInfoTest {
3638
}
3739
}
3840

41+
@Test
42+
fun `userinfo should throw OAuth2Exception when algorithm does not match`() {
43+
val issuerUrl = "http://localhost/default"
44+
val tokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(algorithm = JWSAlgorithm.RS384.name))
45+
val claims = mapOf(
46+
"iss" to issuerUrl,
47+
"sub" to "foo",
48+
"extra" to "bar"
49+
)
50+
val bearerToken = tokenProvider.jwt(claims)
51+
val request = request("$issuerUrl$USER_INFO", bearerToken.serialize())
52+
53+
shouldThrow<OAuth2Exception> {
54+
routes {
55+
userInfo(tokenProvider)
56+
}.invoke(request)
57+
}.asClue {
58+
it.errorObject?.code shouldBe "invalid_token"
59+
it.errorObject?.description shouldBe "Signed JWT rejected: Another algorithm expected, or no matching key(s) found"
60+
it.errorObject?.httpStatusCode shouldBe 401
61+
}
62+
}
63+
3964
@Test
4065
fun `userinfo should throw OAuth2Exception when bearer token is missing`() {
4166
val url = "http://localhost/default$USER_INFO"

0 commit comments

Comments
 (0)