Skip to content

Commit 600c2ec

Browse files
ybelMekktommytroen
andauthored
support tokens without audience and scope from claims when using JwtBearerGrant (#13)
* breaking-change: allow tokens without audience to be provided via OAuth2TokenCallback.kt * api change on OAuth2TokenCallback.kt, audience now returns List<String> instead of String * an empty list for audience() in OAuth2TokenCallback.kt will yield a token without audience * support returned response scope from assertion claim Co-authored-by: Tommy Trøen <tommy.troen@nav.no>
1 parent 94fcbfa commit 600c2ec

File tree

9 files changed

+91
-25
lines changed

9 files changed

+91
-25
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class MockOAuth2Server(
9090
DefaultOAuth2TokenCallback(
9191
issuerId,
9292
subject,
93-
audience,
93+
audience?.let { listOf(it) },
9494
claims,
9595
expiry
9696
)

src/main/kotlin/no/nav/security/mock/oauth2/grant/AuthorizationCodeGrantHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ internal class AuthorizationCodeHandler(
9393
private class LoginOAuth2TokenCallback(val login: Login, val OAuth2TokenCallback: OAuth2TokenCallback) : OAuth2TokenCallback {
9494
override fun issuerId(): String = OAuth2TokenCallback.issuerId()
9595
override fun subject(tokenRequest: TokenRequest): String = login.username
96-
override fun audience(tokenRequest: TokenRequest): String = OAuth2TokenCallback.audience(tokenRequest)
96+
override fun audience(tokenRequest: TokenRequest): List<String> = OAuth2TokenCallback.audience(tokenRequest)
9797
override fun addClaims(tokenRequest: TokenRequest): Map<String, Any> =
9898
OAuth2TokenCallback.addClaims(tokenRequest).toMutableMap().apply {
9999
login.acr?.let { put("acr", it) }

src/main/kotlin/no/nav/security/mock/oauth2/grant/JwtBearerGrantHandler.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import no.nav.security.mock.oauth2.OAuth2Exception
88
import no.nav.security.mock.oauth2.extensions.expiresIn
99
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
1010
import no.nav.security.mock.oauth2.http.OAuth2TokenResponse
11+
import no.nav.security.mock.oauth2.invalidRequest
1112
import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
1213
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1314
import okhttp3.HttpUrl
@@ -20,7 +21,7 @@ internal class JwtBearerGrantHandler(private val tokenProvider: OAuth2TokenProvi
2021
oAuth2TokenCallback: OAuth2TokenCallback
2122
): OAuth2TokenResponse {
2223
val tokenRequest = request.asNimbusTokenRequest()
23-
val receivedClaimsSet = assertion(tokenRequest)
24+
val receivedClaimsSet = tokenRequest.assertion()
2425
val accessToken = tokenProvider.exchangeAccessToken(
2526
tokenRequest,
2627
issuerUrl,
@@ -31,11 +32,17 @@ internal class JwtBearerGrantHandler(private val tokenProvider: OAuth2TokenProvi
3132
tokenType = "Bearer",
3233
accessToken = accessToken.serialize(),
3334
expiresIn = accessToken.expiresIn(),
34-
scope = tokenRequest.scope.toString()
35+
scope = tokenRequest.responseScope()
3536
)
3637
}
3738

38-
private fun assertion(tokenRequest: TokenRequest): JWTClaimsSet =
39-
(tokenRequest.authorizationGrant as? JWTBearerGrant)?.jwtAssertion?.jwtClaimsSet
39+
private fun TokenRequest.responseScope(): String {
40+
return scope?.toString()
41+
?: assertion().getClaim("scope")?.toString()
42+
?: invalidRequest("scope must be specified in request or as a claim in assertion parameter")
43+
}
44+
45+
private fun TokenRequest.assertion(): JWTClaimsSet =
46+
(this.authorizationGrant as? JWTBearerGrant)?.jwtAssertion?.jwtClaimsSet
4047
?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "missing required parameter assertion")
4148
}

src/main/kotlin/no/nav/security/mock/oauth2/grant/TokenExchangeGrant.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ val TOKEN_EXCHANGE = GrantType("urn:ietf:params:oauth:grant-type:token-exchange"
1010
class TokenExchangeGrant(
1111
val subjectTokenType: String,
1212
val subjectToken: String,
13-
val audience: String
13+
val audience: MutableList<String>
1414
) : AuthorizationGrant(TOKEN_EXCHANGE) {
1515

1616
override fun toParameters(): MutableMap<String, MutableList<String>> =
1717
mutableMapOf(
1818
"grant_type" to mutableListOf(TOKEN_EXCHANGE.value),
1919
"subject_token_type" to mutableListOf(subjectTokenType),
2020
"subject_token" to mutableListOf(subjectToken),
21-
"audience" to mutableListOf(audience)
21+
"audience" to audience
2222
)
2323

2424
companion object {
@@ -27,6 +27,8 @@ class TokenExchangeGrant(
2727
parameters.require("subject_token_type"),
2828
parameters.require("subject_token"),
2929
parameters.require("audience")
30+
.split(" ")
31+
.toMutableList()
3032
)
3133
}
3234
}

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ import com.nimbusds.openid.connect.sdk.OIDCScopeValue
66
import java.util.UUID
77
import no.nav.security.mock.oauth2.extensions.clientIdAsString
88
import no.nav.security.mock.oauth2.extensions.grantType
9+
import no.nav.security.mock.oauth2.grant.TokenExchangeGrant
910

1011
interface OAuth2TokenCallback {
1112
fun issuerId(): String
1213
fun subject(tokenRequest: TokenRequest): String
13-
fun audience(tokenRequest: TokenRequest): String
14+
fun audience(tokenRequest: TokenRequest): List<String>
1415
fun addClaims(tokenRequest: TokenRequest): Map<String, Any>
1516
fun tokenExpiry(): Long
1617
}
@@ -19,7 +20,8 @@ interface OAuth2TokenCallback {
1920
open class DefaultOAuth2TokenCallback(
2021
private val issuerId: String = "default",
2122
private val subject: String = UUID.randomUUID().toString(),
22-
private val audience: String? = null,
23+
// needs to be nullable in order to know if a list has explicitly been set, empty list should be a allowable value
24+
private val audience: List<String>? = null,
2325
private val claims: Map<String, Any> = emptyMap(),
2426
private val expiry: Long = 3600
2527
) : OAuth2TokenCallback {
@@ -33,15 +35,14 @@ open class DefaultOAuth2TokenCallback(
3335
}
3436
}
3537

36-
override fun audience(tokenRequest: TokenRequest): String {
38+
override fun audience(tokenRequest: TokenRequest): List<String> {
3739
val oidcScopeList = OIDCScopeValue.values().map { it.toString() }
3840
return audience
41+
?: (tokenRequest.authorizationGrant as? TokenExchangeGrant)?.audience
3942
?: let {
4043
tokenRequest.scope?.toStringList()
41-
?.filterNot { oidcScopeList.contains(it) }?.firstOrNull()
42-
}
43-
?: tokenRequest.customParameters["audience"]?.first()
44-
?: "default"
44+
?.filterNot { oidcScopeList.contains(it) }
45+
} ?: listOf("default")
4546
}
4647

4748
override fun addClaims(tokenRequest: TokenRequest): Map<String, Any> =

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class OAuth2TokenProvider {
3636
defaultClaims(
3737
issuerUrl,
3838
oAuth2TokenCallback.subject(tokenRequest),
39-
tokenRequest.clientIdAsString(),
39+
listOf(tokenRequest.clientIdAsString()),
4040
nonce,
4141
oAuth2TokenCallback.addClaims(tokenRequest),
4242
oAuth2TokenCallback.tokenExpiry()
@@ -90,7 +90,7 @@ class OAuth2TokenProvider {
9090
private fun defaultClaims(
9191
issuerUrl: HttpUrl,
9292
subject: String,
93-
audience: String,
93+
audience: List<String>,
9494
nonce: String?,
9595
additionalClaims: Map<String, Any>,
9696
expiry: Long

src/test/kotlin/no/nav/security/mock/oauth2/MockOAuth2ServerTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ class MockOAuth2ServerTest {
261261
DefaultOAuth2TokenCallback(
262262
issuerId = "custom",
263263
subject = "yolo",
264-
audience = "myaud"
264+
audience = listOf("myaud")
265265
)
266266
)
267267

@@ -322,7 +322,7 @@ class MockOAuth2ServerTest {
322322
DefaultOAuth2TokenCallback(
323323
issuerId = "default",
324324
subject = "mysub",
325-
audience = "muyaud",
325+
audience = listOf("muyaud"),
326326
claims = mapOf("someclaim" to "claimvalue")
327327
)
328328
)

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package no.nav.security.mock.oauth2.e2e
22

33
import com.nimbusds.oauth2.sdk.GrantType
4+
import io.kotest.matchers.collections.shouldBeEmpty
45
import io.kotest.matchers.collections.shouldContainExactly
6+
import io.kotest.matchers.nulls.shouldNotBeNull
57
import io.kotest.matchers.should
68
import io.kotest.matchers.shouldBe
79
import io.kotest.matchers.string.shouldContain
@@ -62,4 +64,52 @@ class JwtBearerGrantIntegrationTest {
6264
response.accessToken.claims["claim2"] shouldBe "value2"
6365
}
6466
}
67+
68+
@Test
69+
fun `token request with JwtBearerGrant should exchange assertion with a new token with scope specified in assertion claim or request parmas`() {
70+
withMockOAuth2Server {
71+
val initialSubject = "mysub"
72+
val initialToken = this.issueToken(
73+
issuerId = "idprovider",
74+
clientId = "client1",
75+
tokenCallback = DefaultOAuth2TokenCallback(
76+
issuerId = "idprovider",
77+
subject = initialSubject,
78+
audience = emptyList(),
79+
claims = mapOf(
80+
"claim1" to "value1",
81+
"claim2" to "value2",
82+
"scope" to "ascope",
83+
"resource" to "aud1",
84+
)
85+
)
86+
)
87+
88+
initialToken.audience.shouldBeEmpty()
89+
90+
val issuerId = "aad"
91+
92+
this.enqueueCallback(DefaultOAuth2TokenCallback(issuerId = issuerId, audience = emptyList()))
93+
94+
val response: ParsedTokenResponse = client.tokenRequest(
95+
url = this.tokenEndpointUrl(issuerId),
96+
parameters = mapOf(
97+
"grant_type" to GrantType.JWT_BEARER.value,
98+
"assertion" to initialToken.serialize()
99+
)
100+
).toTokenResponse()
101+
102+
println("YOLO:" + response.accessToken?.serialize())
103+
104+
response shouldBeValidFor GrantType.JWT_BEARER
105+
response.scope shouldContain "ascope"
106+
response.issuedTokenType shouldBe null
107+
response.accessToken.shouldNotBeNull()
108+
response.accessToken should verifyWith(issuerId, this, listOf("sub", "iss", "iat", "exp"))
109+
response.accessToken.subject shouldBe initialSubject
110+
response.accessToken.audience.shouldBeEmpty()
111+
response.accessToken.claims["claim1"] shouldBe "value1"
112+
response.accessToken.claims["claim2"] shouldBe "value2"
113+
}
114+
}
65115
}

src/test/kotlin/no/nav/security/mock/oauth2/testutils/Token.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,14 @@ infix fun ParsedTokenResponse.shouldBeValidFor(type: GrantType) {
7979
}
8080
}
8181

82-
fun verifyWith(issuerId: String, server: MockOAuth2Server) = object : Matcher<SignedJWT> {
82+
fun verifyWith(
83+
issuerId: String,
84+
server: MockOAuth2Server,
85+
requiredClaims: List<String> = listOf("sub", "iss", "iat", "exp", "aud")
86+
) = object : Matcher<SignedJWT> {
8387
override fun test(value: SignedJWT): MatcherResult {
8488
return try {
85-
value.verifyWith(server.issuerUrl(issuerId), server.jwksUrl(issuerId))
89+
value.verifyWith(server.issuerUrl(issuerId), server.jwksUrl(issuerId), requiredClaims)
8690
MatcherResult(
8791
true,
8892
"should not happen, famous last words",
@@ -105,17 +109,19 @@ val SignedJWT.issuer: String get() = jwtClaimsSet.issuer
105109
val SignedJWT.subject: String get() = jwtClaimsSet.subject
106110
val SignedJWT.claims: Map<String, Any> get() = jwtClaimsSet.claims
107111

108-
fun SignedJWT.verifyWith(issuer: HttpUrl, jwkSetUri: HttpUrl): JWTClaimsSet {
112+
fun SignedJWT.verifyWith(
113+
issuer: HttpUrl,
114+
jwkSetUri: HttpUrl,
115+
requiredClaims: List<String> = listOf("sub", "iss", "iat", "exp", "aud")
116+
): JWTClaimsSet {
109117
return DefaultJWTProcessor<SecurityContext?>()
110118
.apply {
111119
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.RS256, RemoteJWKSet(jwkSetUri.toUrl()))
112120
jwtClaimsSetVerifier = DefaultJWTClaimsVerifier(
113121
JWTClaimsSet.Builder()
114122
.issuer(issuer.toString())
115123
.build(),
116-
HashSet(
117-
listOf("sub", "iss", "iat", "exp", "aud")
118-
)
124+
HashSet(requiredClaims)
119125
)
120126
}.process(this, null)
121127
}

0 commit comments

Comments
 (0)