Skip to content

Commit 5fe5d8e

Browse files
authored
feat: support custom TimeProvider when validating tokens (introspect, userinfo) (#730)
* feat: support custom TimeProvider when validating tokens * add verify function to OAuth2TokenProvider and use the TimeProvider if set - i.e. via overriding Nimbus DefaultJWTClaimsVerifier's currentTime function * refactor tests for simplicity * fix: use jwkSelector when returning keys in KeyProvider * necessary to use jwkSelector to only get keys for supported algorithm * use Instant.now for currentTime when TimeProvider not set
1 parent 014faf0 commit 5fe5d8e

File tree

7 files changed

+140
-85
lines changed

7 files changed

+140
-85
lines changed

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

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ package no.nav.security.mock.oauth2.introspect
33
import com.fasterxml.jackson.annotation.JsonInclude
44
import com.fasterxml.jackson.annotation.JsonProperty
55
import com.nimbusds.jwt.JWTClaimsSet
6-
import com.nimbusds.jwt.SignedJWT
76
import com.nimbusds.oauth2.sdk.OAuth2Error
8-
import com.nimbusds.oauth2.sdk.id.Issuer
97
import mu.KotlinLogging
108
import no.nav.security.mock.oauth2.OAuth2Exception
119
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
12-
import no.nav.security.mock.oauth2.extensions.issuerId
1310
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
14-
import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer
1511
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
1612
import no.nav.security.mock.oauth2.http.Route
1713
import no.nav.security.mock.oauth2.http.json
@@ -51,12 +47,10 @@ internal fun Route.Builder.introspect(tokenProvider: OAuth2TokenProvider) =
5147
}
5248

5349
private fun OAuth2HttpRequest.verifyToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet? {
54-
val tokenString = this.formParameters.get("token")
55-
val issuer = url.toIssuerUrl()
56-
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
57-
val algorithm = tokenProvider.getAlgorithm()
5850
return try {
59-
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet, algorithm)
51+
this.formParameters.get("token")?.let {
52+
tokenProvider.verify(url.toIssuerUrl(), it)
53+
}
6054
} catch (e: Exception) {
6155
log.debug("token_introspection: failed signature validation")
6256
return null

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package no.nav.security.mock.oauth2.token
33
import com.nimbusds.jose.JWSAlgorithm
44
import com.nimbusds.jose.jwk.ECKey
55
import com.nimbusds.jose.jwk.JWK
6+
import com.nimbusds.jose.jwk.JWKSelector
67
import com.nimbusds.jose.jwk.JWKSet
78
import com.nimbusds.jose.jwk.KeyType
89
import com.nimbusds.jose.jwk.RSAKey
10+
import com.nimbusds.jose.jwk.source.JWKSource
11+
import com.nimbusds.jose.proc.SecurityContext
912
import no.nav.security.mock.oauth2.OAuth2Exception
1013
import java.util.concurrent.ConcurrentHashMap
1114
import java.util.concurrent.LinkedBlockingDeque
@@ -15,7 +18,7 @@ open class KeyProvider
1518
constructor(
1619
private val initialKeys: List<JWK> = keysFromFile(INITIAL_KEYS_FILE),
1720
private val algorithm: String = JWSAlgorithm.RS256.name,
18-
) {
21+
) : JWKSource<SecurityContext> {
1922
private val signingKeys: ConcurrentHashMap<String, JWK> = ConcurrentHashMap()
2023

2124
private var generator: KeyGenerator = KeyGenerator(JWSAlgorithm.parse(algorithm))
@@ -35,9 +38,11 @@ open class KeyProvider
3538
KeyType.RSA.value -> {
3639
RSAKey.Builder(polledJwk.toRSAKey()).keyID(keyId).build()
3740
}
41+
3842
KeyType.EC.value -> {
3943
ECKey.Builder(polledJwk.toECKey()).keyID(keyId).build()
4044
}
45+
4146
else -> {
4247
throw OAuth2Exception("Unsupported key type: ${polledJwk.keyType.value}")
4348
}
@@ -63,4 +68,10 @@ open class KeyProvider
6368
return emptyList()
6469
}
6570
}
71+
72+
override fun get(
73+
jwkSelector: JWKSelector?,
74+
context: SecurityContext?,
75+
): MutableList<JWK> = jwkSelector?.select(JWKSet(signingKeys.values.toList()).toPublicJWKSet()) ?: mutableListOf()
76+
6677
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import com.nimbusds.jose.crypto.ECDSASigner
77
import com.nimbusds.jose.crypto.RSASSASigner
88
import com.nimbusds.jose.jwk.JWKSet
99
import com.nimbusds.jose.jwk.KeyType
10+
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier
11+
import com.nimbusds.jose.proc.JWSVerificationKeySelector
12+
import com.nimbusds.jose.proc.SecurityContext
1013
import com.nimbusds.jwt.JWTClaimsSet
1114
import com.nimbusds.jwt.SignedJWT
15+
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
16+
import com.nimbusds.jwt.proc.DefaultJWTProcessor
1217
import com.nimbusds.oauth2.sdk.TokenRequest
1318
import no.nav.security.mock.oauth2.OAuth2Exception
1419
import no.nav.security.mock.oauth2.extensions.clientIdAsString
@@ -106,6 +111,11 @@ class OAuth2TokenProvider
106111
builder.build()
107112
}.sign(issuerId, JOSEObjectType.JWT.type)
108113

114+
fun verify(
115+
issuerUrl: HttpUrl,
116+
token: String,
117+
): JWTClaimsSet = SignedJWT.parse(token).verify(issuerUrl)
118+
109119
private fun JWTClaimsSet.sign(
110120
issuerId: String,
111121
type: String,
@@ -124,6 +134,7 @@ class OAuth2TokenProvider
124134
sign(RSASSASigner(key.toRSAKey().toPrivateKey()))
125135
}
126136
}
137+
127138
supported && keyType == KeyType.EC.value -> {
128139
SignedJWT(
129140
jwsHeader(key.keyID, type, algorithm),
@@ -132,6 +143,7 @@ class OAuth2TokenProvider
132143
sign(ECDSASigner(key.toECKey().toECPrivateKey()))
133144
}
134145
}
146+
135147
else -> {
136148
throw OAuth2Exception("Unsupported algorithm: ${algorithm.name}")
137149
}
@@ -178,4 +190,20 @@ class OAuth2TokenProvider
178190
}
179191

180192
private fun Instant?.orNow(): Instant = this ?: Instant.now()
193+
194+
private fun SignedJWT.verify(issuerUrl: HttpUrl): JWTClaimsSet {
195+
val jwtProcessor =
196+
DefaultJWTProcessor<SecurityContext?>().apply {
197+
jwsTypeVerifier = DefaultJOSEObjectTypeVerifier(JOSEObjectType("JWT"))
198+
jwsKeySelector = JWSVerificationKeySelector(keyProvider.algorithm(), keyProvider)
199+
jwtClaimsSetVerifier =
200+
object : DefaultJWTClaimsVerifier<SecurityContext?>(
201+
JWTClaimsSet.Builder().issuer(issuerUrl.toString()).build(),
202+
HashSet(listOf("iat", "exp")),
203+
) {
204+
override fun currentTime(): Date = Date.from(timeProvider().orNow())
205+
}
206+
}
207+
return jwtProcessor.process(this, null)
208+
}
181209
}

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

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
package no.nav.security.mock.oauth2.userinfo
22

33
import com.nimbusds.jwt.JWTClaimsSet
4-
import com.nimbusds.jwt.SignedJWT
54
import com.nimbusds.oauth2.sdk.ErrorObject
65
import com.nimbusds.oauth2.sdk.http.HTTPResponse
7-
import com.nimbusds.oauth2.sdk.id.Issuer
86
import mu.KotlinLogging
97
import no.nav.security.mock.oauth2.OAuth2Exception
108
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO
11-
import no.nav.security.mock.oauth2.extensions.issuerId
129
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
13-
import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer
1410
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
1511
import no.nav.security.mock.oauth2.http.Route
1612
import no.nav.security.mock.oauth2.http.json
@@ -26,17 +22,12 @@ internal fun Route.Builder.userInfo(tokenProvider: OAuth2TokenProvider) =
2622
json(claims)
2723
}
2824

29-
private fun OAuth2HttpRequest.verifyBearerToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet {
30-
val tokenString = this.headers.bearerToken()
31-
val issuer = url.toIssuerUrl()
32-
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
33-
val algorithm = tokenProvider.getAlgorithm()
34-
return try {
35-
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet, algorithm)
25+
private fun OAuth2HttpRequest.verifyBearerToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet =
26+
try {
27+
tokenProvider.verify(url.toIssuerUrl(), this.headers.bearerToken())
3628
} catch (e: Exception) {
3729
throw invalidToken(e.message ?: "could not verify bearer token")
3830
}
39-
}
4031

4132
private fun Headers.bearerToken(): String =
4233
this["Authorization"]

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1919
import okhttp3.Headers
2020
import okhttp3.HttpUrl.Companion.toHttpUrl
2121
import org.junit.jupiter.api.Test
22+
import java.time.Instant
23+
import java.time.temporal.ChronoUnit
2224

2325
internal class IntrospectTest {
2426
private val rs384TokenProvider = OAuth2TokenProvider(keyProvider = KeyProvider(initialKeys = emptyList(), algorithm = JWSAlgorithm.RS384.name))
@@ -66,6 +68,29 @@ internal class IntrospectTest {
6668
}
6769
}
6870

71+
@Test
72+
fun `introspect should return active and claims from token when using a custom timeProvider in the OAuth2TokenProvider`() {
73+
val issuerUrl = "http://localhost/default"
74+
val yesterday = Instant.now().minus(1, ChronoUnit.DAYS)
75+
val tokenProvider = OAuth2TokenProvider(timeProvider = { yesterday })
76+
val claims =
77+
mapOf(
78+
"iss" to issuerUrl,
79+
"client_id" to "yolo",
80+
"token_type" to "token",
81+
"sub" to "foo",
82+
)
83+
val token = tokenProvider.jwt(claims)
84+
val request = request("$issuerUrl$INTROSPECT", token.serialize())
85+
86+
routes { introspect(tokenProvider) }.invoke(request).asClue {
87+
it.status shouldBe 200
88+
val response = it.parse<Map<String, Any>>()
89+
response shouldContainAll claims
90+
response shouldContain ("active" to true)
91+
}
92+
}
93+
6994
@Test
7095
fun `introspect should return active false when token is missing`() {
7196
val url = "http://localhost/default$INTROSPECT"

src/test/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenProviderRSATest.kt

Lines changed: 47 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
1616
import org.junit.jupiter.api.Test
1717
import org.junit.jupiter.params.ParameterizedTest
1818
import org.junit.jupiter.params.provider.ValueSource
19-
import java.time.Clock
2019
import java.time.Instant
21-
import java.time.ZoneId
2220
import java.time.temporal.ChronoUnit
2321
import java.util.Date
2422

@@ -106,87 +104,71 @@ internal class OAuth2TokenProviderRSATest {
106104
val yesterday = Instant.now().minus(1, ChronoUnit.DAYS)
107105
val tokenProvider = OAuth2TokenProvider(systemTime = yesterday)
108106

109-
tokenProvider
110-
.exchangeAccessToken(
111-
tokenRequest =
112-
nimbusTokenRequest(
113-
"id",
114-
"grant_type" to GrantType.CLIENT_CREDENTIALS.value,
115-
"scope" to "scope1",
116-
),
117-
issuerUrl = "http://default_if_not_overridden".toHttpUrl(),
118-
claimsSet = tokenProvider.jwt(mapOf()).jwtClaimsSet,
119-
oAuth2TokenCallback = DefaultOAuth2TokenCallback(),
120-
).asClue {
121-
it.jwtClaimsSet.issueTime shouldBe Date.from(tokenProvider.systemTime)
122-
println(it.serialize())
123-
}
107+
tokenProvider.clientCredentialsToken("http://localhost/default").asClue {
108+
it.jwtClaimsSet.issueTime shouldBe Date.from(tokenProvider.systemTime)
109+
}
110+
111+
val now = Instant.now().minus(1, ChronoUnit.SECONDS)
112+
OAuth2TokenProvider().clientCredentialsToken("http://localhost/default").asClue {
113+
it.jwtClaimsSet.issueTime shouldBeAfter now
114+
}
124115
}
125116

126117
@Test
127118
fun `token should have issuedAt set dynamically according to timeProvider`() {
128-
val clock =
129-
object : Clock() {
130-
private var clock = systemDefaultZone()
119+
val timeProvider =
120+
object : TimeProvider {
121+
var time = Instant.now()
131122

132-
override fun instant() = clock.instant()
133-
134-
override fun withZone(zone: ZoneId) = clock.withZone(zone)
135-
136-
override fun getZone() = clock.zone
137-
138-
fun fixed(instant: Instant) {
139-
clock = fixed(instant, zone)
140-
}
123+
override fun invoke(): Instant = time
141124
}
142125

143-
val tokenProvider = OAuth2TokenProvider { clock.instant() }
126+
val tokenProvider = OAuth2TokenProvider(timeProvider = timeProvider)
144127

145128
val instant1 = Instant.parse("2000-12-03T10:15:30.00Z")
146129
val instant2 = Instant.parse("2020-01-21T00:00:00.00Z")
147-
instant1 shouldNotBe instant2
148130

149-
run {
150-
clock.fixed(instant1)
151-
tokenProvider.systemTime shouldBe instant1
131+
timeProvider.time = instant1
132+
tokenProvider.systemTime shouldBe instant1
152133

153-
tokenProvider.exchangeAccessToken(
154-
tokenRequest =
155-
nimbusTokenRequest(
156-
"id",
157-
"grant_type" to GrantType.CLIENT_CREDENTIALS.value,
158-
"scope" to "scope1",
159-
),
160-
issuerUrl = "http://default_if_not_overridden".toHttpUrl(),
161-
claimsSet = tokenProvider.jwt(mapOf()).jwtClaimsSet,
162-
oAuth2TokenCallback = DefaultOAuth2TokenCallback(),
163-
)
164-
}.asClue {
134+
tokenProvider.clientCredentialsToken("http://localhost/default").asClue {
165135
it.jwtClaimsSet.issueTime shouldBe Date.from(instant1)
166-
println(it.serialize())
167136
}
168137

169-
run {
170-
clock.fixed(instant2)
171-
tokenProvider.systemTime shouldBe instant2
138+
timeProvider.time = instant2
139+
tokenProvider.systemTime shouldBe instant2
172140

173-
tokenProvider.exchangeAccessToken(
174-
tokenRequest =
175-
nimbusTokenRequest(
176-
"id",
177-
"grant_type" to GrantType.CLIENT_CREDENTIALS.value,
178-
"scope" to "scope1",
179-
),
180-
issuerUrl = "http://default_if_not_overridden".toHttpUrl(),
181-
claimsSet = tokenProvider.jwt(mapOf()).jwtClaimsSet,
182-
oAuth2TokenCallback = DefaultOAuth2TokenCallback(),
183-
)
184-
}.asClue {
141+
tokenProvider.clientCredentialsToken("http://localhost/default").asClue {
185142
it.jwtClaimsSet.issueTime shouldBe Date.from(instant2)
186-
println(it.serialize())
187143
}
188144
}
189145

146+
@Test
147+
fun `token with issueTime set to yesterday should be able to validate with the verify function using the same timeprovider`() {
148+
val yesterday = Instant.now().minus(1, ChronoUnit.DAYS)
149+
val tokenProvider = OAuth2TokenProvider(timeProvider = { yesterday })
150+
151+
val token = tokenProvider.clientCredentialsToken("http://localhost/default")
152+
153+
token.jwtClaimsSet.issueTime shouldBe Date.from(tokenProvider.systemTime)
154+
155+
tokenProvider.verify("http://localhost/default".toHttpUrl(), token.serialize()).toJSONObject().asClue {
156+
it shouldBe token.jwtClaimsSet.toJSONObject()
157+
}
158+
}
159+
160+
private fun OAuth2TokenProvider.clientCredentialsToken(issuerUrl: String): SignedJWT =
161+
accessToken(
162+
tokenRequest =
163+
nimbusTokenRequest(
164+
"client1",
165+
"grant_type" to "client_credentials",
166+
"scope" to "scope1",
167+
),
168+
issuerUrl = issuerUrl.toHttpUrl(),
169+
oAuth2TokenCallback = DefaultOAuth2TokenCallback(),
170+
)
171+
190172
private fun idToken(issuerUrl: String): SignedJWT =
191173
tokenProvider.idToken(
192174
tokenRequest =
@@ -198,4 +180,6 @@ internal class OAuth2TokenProviderRSATest {
198180
issuerUrl = issuerUrl.toHttpUrl(),
199181
oAuth2TokenCallback = DefaultOAuth2TokenCallback(),
200182
)
183+
184+
private infix fun Date.shouldBeAfter(instant: Instant?) = this.after(Date.from(instant)) shouldBe true
201185
}

0 commit comments

Comments
 (0)