Skip to content

Commit 1bd6451

Browse files
authored
Merge pull request #847 from navikt/private-key-jwt-audience
2 parents 28605dc + 62a5bad commit 1bd6451

File tree

7 files changed

+180
-71
lines changed

7 files changed

+180
-71
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ fun missingParameter(name: String): Nothing =
2323

2424
fun invalidGrant(grantType: GrantType): Nothing =
2525
"grant_type $grantType not supported.".let {
26-
throw OAuth2Exception(OAuth2Error.INVALID_GRANT, it)
26+
throw OAuth2Exception(OAuth2Error.INVALID_GRANT.setDescription(it), it)
2727
}
2828

2929
fun invalidRequest(message: String): Nothing =
3030
message.let {
31-
throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, message)
31+
throw OAuth2Exception(OAuth2Error.INVALID_REQUEST.setDescription(message), message)
3232
}
3333

3434
fun notFound(message: String): Nothing = throw OAuth2Exception(ErrorObject("not_found", "Resource not found", HTTPResponse.SC_NOT_FOUND), message)

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)