Skip to content

Commit 62a5bad

Browse files
committed
feat: prefer issuer as client assertion audience for private_key_jwt auth
For backwards compatibility, the previous behavior where the `token_endpoint` was required as audience value is still accepted. Client assertions will however be validated more strictly than previously, to be more in line with the current requirements of RFC 7523. Namely: - the `iss` (issuer) and `sub` (subject) claim values must be equal to the client_id - the `aud` (audience) claim must contain exactly one value
1 parent c3cd3db commit 62a5bad

File tree

6 files changed

+178
-69
lines changed

6 files changed

+178
-69
lines changed

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,56 @@ fun HTTPRequest.clientAuthentication() =
106106
ClientAuthentication.parse(this)
107107
?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "request must contain some form of ClientAuthentication.")
108108

109+
/**
110+
* TODO: We currently accept multiple audiences for backwards compatibility as updates to RFC7523 are pending.
111+
* Relevant excerpts:
112+
* > The JWT MUST contain an aud (audience) claim containing the issuer identifier [RFC8414] of the authorization server as its sole value.
113+
*
114+
* > Unlike the aud value specified in [RFC7523], there MUST be no value other than the issuer identifier of the intended authorization server used as the audience of the JWT;
115+
* > this includes that the token endpoint URL of the authorization server MUST NOT be used as an audience value.
116+
*
117+
* > The authorization server MUST reject any JWT that does not contain its issuer identifier as its sole audience value.
118+
*
119+
* See [RFC7523bis](https://datatracker.ietf.org/doc/draft-ietf-oauth-rfc7523bis) for details.
120+
* Compliance with the RFC will require breaking changes.
121+
**/
109122
fun ClientAuthentication.requirePrivateKeyJwt(
110123
requiredAudience: String,
111124
maxLifetimeSeconds: Long,
125+
additionalAcceptedAudience: String? = null,
112126
): PrivateKeyJWT =
113127
(this as? PrivateKeyJWT)
114128
?.let {
129+
val acceptedAudiences = setOf(requiredAudience, additionalAcceptedAudience).filterNotNull().toSet()
115130
when {
116131
it.clientAssertion.expiresIn() > maxLifetimeSeconds -> {
117-
invalidRequest("invalid client_assertion: client_assertion expiry is too long( should be < $maxLifetimeSeconds)")
132+
invalidRequest("invalid client_assertion: expiry must be less than $maxLifetimeSeconds seconds")
118133
}
119-
!it.clientAssertion.jwtClaimsSet.audience
120-
.contains(requiredAudience) -> {
121-
invalidRequest("invalid client_assertion: client_assertion must contain required audience '$requiredAudience'")
134+
135+
it.clientAssertion.jwtClaimsSet.issuer != it.clientID.value -> {
136+
invalidRequest("invalid client_assertion: issuer must match client_id '${it.clientID.value}'")
137+
}
138+
139+
it.clientAssertion.jwtClaimsSet.subject != it.clientID.value -> {
140+
invalidRequest("invalid client_assertion: subject must match client_id '${it.clientID.value}'")
141+
}
142+
143+
it.clientAssertion.jwtClaimsSet.audience
144+
.isEmpty() -> {
145+
invalidRequest("invalid client_assertion: audience cannot be empty")
122146
}
147+
148+
it.clientAssertion.jwtClaimsSet.audience.size > 1 -> {
149+
invalidRequest("invalid client_assertion: audience must not contain more than one element")
150+
}
151+
152+
// audience is a List<String>; it should contain exactly one element as per previous checks
153+
it.clientAssertion.jwtClaimsSet.audience
154+
.first() !in acceptedAudiences -> {
155+
invalidRequest("invalid client_assertion: audience should be $requiredAudience")
156+
}
157+
158+
// all checks passed
123159
else -> it
124160
}
125161
} ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "request must contain a valid client_assertion.")

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ data class OAuth2HttpRequest(
3535
val httpRequest: HTTPRequest = this.asNimbusHTTPRequest()
3636
var clientAuthentication = httpRequest.clientAuthentication()
3737
if (clientAuthentication.method == ClientAuthenticationMethod.PRIVATE_KEY_JWT) {
38-
clientAuthentication = clientAuthentication.requirePrivateKeyJwt(this.url.toString(), 120)
38+
clientAuthentication =
39+
clientAuthentication.requirePrivateKeyJwt(
40+
requiredAudience = this.url.toIssuerUrl().toString(),
41+
maxLifetimeSeconds = 120,
42+
additionalAcceptedAudience = this.url.toTokenEndpointUrl().toString(),
43+
)
3944
}
4045
val tokenExchangeGrant = TokenExchangeGrant.parse(formParameters.map)
4146

src/test/kotlin/examples/kotlin/ktor/client/OAuth2Client.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,26 +111,26 @@ class Auth internal constructor(
111111
fun privateKeyJwt(
112112
keyPair: KeyPair,
113113
clientId: String,
114-
tokenEndpoint: String,
114+
audience: String,
115115
expiry: Duration = Duration.ofSeconds(120),
116116
): Auth =
117117
Auth(
118118
parameters =
119119
mapOf(
120120
"client_assertion_type" to CLIENT_ASSERTION_TYPE,
121-
"client_assertion" to keyPair.clientAssertion(clientId, tokenEndpoint, expiry),
121+
"client_assertion" to keyPair.clientAssertion(clientId, audience, expiry),
122122
),
123123
)
124124

125125
private fun KeyPair.clientAssertion(
126126
clientId: String,
127-
tokenEndpoint: String,
127+
audience: String,
128128
expiry: Duration = Duration.ofSeconds(120),
129129
): String {
130130
val now = Instant.now()
131131
return JWT
132132
.create()
133-
.withAudience(tokenEndpoint)
133+
.withAudience(audience)
134134
.withIssuer(clientId)
135135
.withSubject(clientId)
136136
.withJWTId(UUID.randomUUID().toString())

src/test/kotlin/examples/kotlin/ktor/client/OAuth2ClientTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,15 @@ internal class OAuth2ClientTest {
5555
runBlocking {
5656
val initialToken = server.issueToken(subject = "enduser")
5757
val tokenEndpointUrl = server.tokenEndpointUrl("default").toString()
58+
val issuerUrl = server.issuerUrl("default").toString()
5859
val tokenResponse =
5960
httpClient.onBehalfOfGrant(
6061
url = tokenEndpointUrl,
6162
auth =
6263
Auth.privateKeyJwt(
6364
keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair(),
6465
clientId = "client1",
65-
tokenEndpoint = tokenEndpointUrl,
66+
audience = issuerUrl,
6667
),
6768
token = initialToken.serialize(),
6869
scope = "targetScope",

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

Lines changed: 82 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package no.nav.security.mock.oauth2.e2e
22

3-
import com.nimbusds.jwt.JWTClaimsSet
43
import io.kotest.matchers.collections.shouldContainExactly
54
import io.kotest.matchers.should
65
import io.kotest.matchers.shouldBe
@@ -11,21 +10,16 @@ import no.nav.security.mock.oauth2.testutils.SubjectTokenType
1110
import no.nav.security.mock.oauth2.testutils.audience
1211
import no.nav.security.mock.oauth2.testutils.claims
1312
import no.nav.security.mock.oauth2.testutils.clientAssertion
14-
import no.nav.security.mock.oauth2.testutils.generateRsaKey
13+
import no.nav.security.mock.oauth2.testutils.issueSubjectToken
1514
import no.nav.security.mock.oauth2.testutils.shouldBeValidFor
16-
import no.nav.security.mock.oauth2.testutils.sign
1715
import no.nav.security.mock.oauth2.testutils.subject
1816
import no.nav.security.mock.oauth2.testutils.toTokenResponse
1917
import no.nav.security.mock.oauth2.testutils.tokenRequest
2018
import no.nav.security.mock.oauth2.testutils.verifyWith
21-
import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback
2219
import no.nav.security.mock.oauth2.withMockOAuth2Server
2320
import okhttp3.OkHttpClient
2421
import okhttp3.Response
2522
import org.junit.jupiter.api.Test
26-
import java.time.Instant
27-
import java.util.Date
28-
import java.util.UUID
2923

3024
class TokenExchangeGrantIntegrationTest {
3125
private val client: OkHttpClient =
@@ -38,25 +32,12 @@ class TokenExchangeGrantIntegrationTest {
3832
fun `token request with token exchange grant should exchange subject_token with a new token containing many of the same claims`() {
3933
withMockOAuth2Server {
4034
val initialSubject = "yolo"
41-
val initialToken =
42-
this.issueToken(
43-
issuerId = "idprovider",
44-
clientId = "initialClient",
45-
tokenCallback =
46-
DefaultOAuth2TokenCallback(
47-
issuerId = "idprovider",
48-
subject = initialSubject,
49-
claims =
50-
mapOf(
51-
"claim1" to "value1",
52-
"claim2" to "value2",
53-
),
54-
),
55-
)
35+
val initialToken = issueSubjectToken(subject = initialSubject)
5636

5737
val issuerId = "tokenx"
5838
val tokenEndpointUrl = this.tokenEndpointUrl(issuerId)
59-
val clientAssertion = clientAssertion("tokenExchangeClient", tokenEndpointUrl.toUrl()).serialize()
39+
val issuerUrl = this.issuerUrl(issuerId)
40+
val clientAssertion = clientAssertion(clientId = "tokenExchangeClient", audience = issuerUrl.toString()).serialize()
6041
val targetAudienceForToken = "targetAudience"
6142

6243
val response: ParsedTokenResponse =
@@ -92,21 +73,7 @@ class TokenExchangeGrantIntegrationTest {
9273
fun `token request with token exchange grant and client basic auth should exchange subject_token with a new token containing many of the same claims`() {
9374
withMockOAuth2Server {
9475
val initialSubject = "yolo"
95-
val initialToken =
96-
this.issueToken(
97-
issuerId = "idprovider",
98-
clientId = "initialClient",
99-
tokenCallback =
100-
DefaultOAuth2TokenCallback(
101-
issuerId = "idprovider",
102-
subject = initialSubject,
103-
claims =
104-
mapOf(
105-
"claim1" to "value1",
106-
"claim2" to "value2",
107-
),
108-
),
109-
)
76+
val initialToken = issueSubjectToken(subject = initialSubject)
11077

11178
val issuerId = "tokenx"
11279
val tokenEndpointUrl = this.tokenEndpointUrl(issuerId)
@@ -140,6 +107,46 @@ class TokenExchangeGrantIntegrationTest {
140107
}
141108
}
142109

110+
@Test
111+
fun `token request with client_assertion containing aud equal token endpoint should be allowed`() {
112+
withMockOAuth2Server {
113+
val initialSubject = "yolo"
114+
val initialToken = issueSubjectToken(subject = initialSubject)
115+
116+
val issuerId = "tokenx"
117+
val tokenEndpointUrl = this.tokenEndpointUrl(issuerId)
118+
val clientAssertion = clientAssertion("clientid", tokenEndpointUrl.toString()).serialize()
119+
val targetAudienceForToken = "targetAudience"
120+
121+
val response: ParsedTokenResponse =
122+
client
123+
.tokenRequest(
124+
url = tokenEndpointUrl,
125+
parameters =
126+
mapOf(
127+
"grant_type" to TOKEN_EXCHANGE.value,
128+
"client_assertion_type" to ClientAssertionType.JWT_BEARER,
129+
"client_assertion" to clientAssertion,
130+
"subject_token_type" to SubjectTokenType.TOKEN_TYPE_JWT,
131+
"subject_token" to initialToken.serialize(),
132+
"audience" to targetAudienceForToken,
133+
),
134+
).toTokenResponse()
135+
136+
response shouldBeValidFor TOKEN_EXCHANGE
137+
response.scope shouldBe null
138+
response.tokenType shouldBe "Bearer"
139+
response.issuedTokenType shouldBe "urn:ietf:params:oauth:token-type:access_token"
140+
141+
response.accessToken!! should verifyWith(issuerId, this)
142+
143+
response.accessToken.subject shouldBe initialSubject
144+
response.accessToken.audience shouldContainExactly listOf(targetAudienceForToken)
145+
response.accessToken.claims["claim1"] shouldBe "value1"
146+
response.accessToken.claims["claim2"] shouldBe "value2"
147+
}
148+
}
149+
143150
@Test
144151
fun `token request without client_assertion should fail`() {
145152
withMockOAuth2Server {
@@ -161,8 +168,10 @@ class TokenExchangeGrantIntegrationTest {
161168
@Test
162169
fun `token request with invalid client_assertion_type should fail`() {
163170
withMockOAuth2Server {
171+
val initialToken = issueSubjectToken(subject = "some-subject")
164172
val tokenEndpointUrl = this.tokenEndpointUrl("tokenx")
165-
val clientAssertion = clientAssertion("tokenExchangeClient", tokenEndpointUrl.toUrl()).serialize()
173+
val issuerUrl = this.issuerUrl("tokenx")
174+
val clientAssertion = clientAssertion(clientId = "tokenExchangeClient", audience = issuerUrl.toString()).serialize()
166175
val response: Response =
167176
client.tokenRequest(
168177
url = tokenEndpointUrl,
@@ -172,8 +181,8 @@ class TokenExchangeGrantIntegrationTest {
172181
"client_assertion_type" to "some-invalid-type",
173182
"client_assertion" to clientAssertion,
174183
"subject_token_type" to SubjectTokenType.TOKEN_TYPE_JWT,
175-
"subject_token" to "na",
176-
"audience" to "na",
184+
"subject_token" to initialToken.serialize(),
185+
"audience" to "targetAudience",
177186
),
178187
)
179188
response.code shouldBe 400
@@ -183,22 +192,39 @@ class TokenExchangeGrantIntegrationTest {
183192
@Test
184193
fun `token request with client_assertion containing invalid aud should fail`() {
185194
withMockOAuth2Server {
195+
val initialToken = issueSubjectToken(subject = "some-subject")
186196
val tokenEndpointUrl = this.tokenEndpointUrl("tokenx")
187197

188-
val clientAssertion =
189-
JWTClaimsSet
190-
.Builder()
191-
.issuer("clientid")
192-
.subject("clientid")
193-
.audience("invalid")
194-
.issueTime(Date.from(Instant.now()))
195-
.expirationTime(Date.from(Instant.now().plusSeconds(120)))
196-
.notBeforeTime(Date.from(Instant.now()))
197-
.jwtID(UUID.randomUUID().toString())
198-
.build()
199-
.sign(generateRsaKey())
200-
.serialize()
198+
val clientAssertion = clientAssertion(clientId = "tokenExchangeClient", audience = "invalid").serialize()
199+
val response: Response =
200+
client.tokenRequest(
201+
url = tokenEndpointUrl,
202+
parameters =
203+
mapOf(
204+
"grant_type" to TOKEN_EXCHANGE.value,
205+
"client_assertion_type" to ClientAssertionType.JWT_BEARER,
206+
"client_assertion" to clientAssertion,
207+
"subject_token_type" to SubjectTokenType.TOKEN_TYPE_JWT,
208+
"subject_token" to initialToken.serialize(),
209+
"audience" to "targetAudience",
210+
),
211+
)
212+
response.code shouldBe 400
213+
}
214+
}
201215

216+
@Test
217+
fun `token request with client_assertion containing multiple audiences should fail`() {
218+
withMockOAuth2Server {
219+
val initialToken = issueSubjectToken(subject = "some-subject")
220+
val tokenEndpointUrl = this.tokenEndpointUrl("tokenx")
221+
val issuerUrl = this.issuerUrl("tokenx")
222+
223+
val clientAssertion =
224+
clientAssertion(
225+
clientId = "tokenExchangeClient",
226+
audiences = listOf(tokenEndpointUrl.toString(), issuerUrl.toString()),
227+
).serialize()
202228
val response: Response =
203229
client.tokenRequest(
204230
url = tokenEndpointUrl,
@@ -208,8 +234,8 @@ class TokenExchangeGrantIntegrationTest {
208234
"client_assertion_type" to ClientAssertionType.JWT_BEARER,
209235
"client_assertion" to clientAssertion,
210236
"subject_token_type" to SubjectTokenType.TOKEN_TYPE_JWT,
211-
"subject_token" to "na",
212-
"audience" to "na",
237+
"subject_token" to initialToken.serialize(),
238+
"audience" to "targetAudience",
213239
),
214240
)
215241
response.code shouldBe 400

0 commit comments

Comments
 (0)