Skip to content

Commit fcb2017

Browse files
authored
feat(refresh_token): rotate refresh tokens if configured to rotate (#645)
* will return a new unique refresh token on refres_token grant if rotateRefreshToken is true * potientially breaking change as the constructor params for OAuth2Config has changed
1 parent ac7900d commit fcb2017

File tree

6 files changed

+99
-39
lines changed

6 files changed

+99
-39
lines changed

README.md

+8-7
Original file line numberDiff line numberDiff line change
@@ -284,13 +284,14 @@ add this to your config with preferred `JWS algorithm`:
284284
}
285285
```
286286

287-
| Property | Description |
288-
|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
289-
| `interactiveLogin` | `true` or `false`, enables login screen when redirecting to server `/authorize` endpoint |
290-
| `loginPagePath` | An optional string refering to a html file that is served as login page. This page needs to contain a form that posts a `username` and optionally a `claims` field. See `src/test/resource/login.example.html` as an example. |
291-
| `staticAssetsPath` | The path to a directory containing static resources/assets. Lets you serve your own static resources from the server. Resources are served under the `/static` URL path. E.g. http://localhost:8080/static/myimage.svg or by reference `/static/myimage.svg` from the login page. | |
292-
| `httpServer` | A string identifying the httpserver to use. Must match one of the following enum values: `MockWebServerWrapper` or `NettyWrapper` |
293-
| `tokenCallbacks` | A list of [`RequestMappingTokenCallback`](src/main/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenCallback.kt) that lets you specify which token claims to return when a token request matches the specified condition. |
287+
| Property | Description |
288+
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
289+
| `interactiveLogin` | `true` or `false`, enables login screen when redirecting to server `/authorize` endpoint |
290+
| `loginPagePath` | An optional string refering to a html file that is served as login page. This page needs to contain a form that posts a `username` and optionally a `claims` field. See `src/test/resource/login.example.html` as an example. |
291+
| `staticAssetsPath` | The path to a directory containing static resources/assets. Lets you serve your own static resources from the server. Resources are served under the `/static` URL path. E.g. http://localhost:8080/static/myimage.svg or by reference `/static/myimage.svg` from the login page. | |
292+
| `rotateRefreshToken` | `true` or `false`, setting to true will generate a new unique refresh token when using the `refresh_token` grant. |
293+
| `httpServer` | A string identifying the httpserver to use. Must match one of the following enum values: `MockWebServerWrapper` or `NettyWrapper` |
294+
| `tokenCallbacks` | A list of [`RequestMappingTokenCallback`](src/main/kotlin/no/nav/security/mock/oauth2/token/OAuth2TokenCallback.kt) that lets you specify which token claims to return when a token request matches the specified condition. |
294295

295296
*From the JSON example above:*
296297

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

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ data class OAuth2Config
2626
val interactiveLogin: Boolean = false,
2727
val loginPagePath: String? = null,
2828
val staticAssetsPath: String? = null,
29+
val rotateRefreshToken: Boolean = false,
2930
@JsonDeserialize(using = OAuth2TokenProviderDeserializer::class)
3031
val tokenProvider: OAuth2TokenProvider = OAuth2TokenProvider(),
3132
@JsonDeserialize(contentAs = RequestMappingTokenCallback::class)

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ private val log = KotlinLogging.logger {}
1818
internal class RefreshTokenGrantHandler(
1919
private val tokenProvider: OAuth2TokenProvider,
2020
private val refreshTokenManager: RefreshTokenManager,
21+
private val rotateRefreshToken: Boolean = false,
2122
) : GrantHandler {
2223
override fun tokenResponse(
2324
request: OAuth2HttpRequest,
2425
issuerUrl: HttpUrl,
2526
oAuth2TokenCallback: OAuth2TokenCallback,
2627
): OAuth2TokenResponse {
2728
val tokenRequest = request.asNimbusTokenRequest()
28-
val refreshToken = tokenRequest.refreshTokenGrant().refreshToken.value
29+
var refreshToken = tokenRequest.refreshTokenGrant().refreshToken.value
2930
log.debug("issuing token for refreshToken=$refreshToken")
3031
val scope: String? = tokenRequest.scope?.toString()
3132
val refreshTokenCallbackOrDefault = refreshTokenManager[refreshToken] ?: oAuth2TokenCallback
33+
if (rotateRefreshToken) {
34+
refreshToken = refreshTokenManager.rotate(refreshToken, refreshTokenCallbackOrDefault)
35+
}
3236
val idToken: SignedJWT = tokenProvider.idToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault)
3337
val accessToken: SignedJWT = tokenProvider.accessToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault)
3438

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
66
import java.util.UUID
77

88
typealias RefreshToken = String
9+
typealias Nonce = String
910

1011
internal data class RefreshTokenManager(
1112
private val cache: MutableMap<RefreshToken, OAuth2TokenCallback> = HashMap(),
@@ -16,7 +17,7 @@ internal data class RefreshTokenManager(
1617

1718
fun refreshToken(
1819
tokenCallback: OAuth2TokenCallback,
19-
nonce: String?,
20+
nonce: Nonce? = null,
2021
): RefreshToken {
2122
val jti = UUID.randomUUID().toString()
2223
// added for compatibility with keycloak js client which expects a jwt with nonce
@@ -25,6 +26,14 @@ internal data class RefreshTokenManager(
2526
return refreshToken
2627
}
2728

29+
fun rotate(
30+
refreshToken: RefreshToken,
31+
fallbackTokenCallback: OAuth2TokenCallback,
32+
): RefreshToken {
33+
val callback = cache.remove(refreshToken) ?: fallbackTokenCallback
34+
return refreshToken(callback)
35+
}
36+
2837
private fun plainJWT(
2938
jti: String,
3039
nonce: String?,

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
6565
CLIENT_CREDENTIALS to ClientCredentialsGrantHandler(config.tokenProvider),
6666
JWT_BEARER to JwtBearerGrantHandler(config.tokenProvider),
6767
TOKEN_EXCHANGE to TokenExchangeGrantHandler(config.tokenProvider),
68-
REFRESH_TOKEN to RefreshTokenGrantHandler(config.tokenProvider, refreshTokenManager),
68+
REFRESH_TOKEN to RefreshTokenGrantHandler(config.tokenProvider, refreshTokenManager, config.rotateRefreshToken),
6969
PASSWORD to PasswordGrantHandler(config.tokenProvider),
7070
)
7171

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

+74-29
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package no.nav.security.mock.oauth2.e2e
22

33
import com.nimbusds.oauth2.sdk.GrantType
4+
import io.kotest.assertions.asClue
45
import io.kotest.matchers.nulls.shouldNotBeNull
56
import io.kotest.matchers.should
67
import io.kotest.matchers.shouldBe
78
import io.kotest.matchers.shouldNotBe
9+
import no.nav.security.mock.oauth2.MockOAuth2Server
10+
import no.nav.security.mock.oauth2.OAuth2Config
11+
import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse
812
import no.nav.security.mock.oauth2.testutils.audience
913
import no.nav.security.mock.oauth2.testutils.authenticationRequest
1014
import no.nav.security.mock.oauth2.testutils.client
@@ -24,38 +28,48 @@ class RefreshTokenGrantIntegrationTest {
2428
private val client: OkHttpClient = client()
2529

2630
@Test
27-
fun `token request with refresh_token grant should return id_token and access_token with same subject as authorization code grant`() {
31+
fun `refresh_token grant should return id_token and access_token with same subject as authorization code grant`() {
2832
withMockOAuth2Server {
2933
val initialSubject = "yolo"
3034
val issuerId = "idprovider"
3135

32-
// Authenticate using Authorization Code Flow
33-
// simulate user interaction by doing the auth request as a post (instead of get with user punching username/pwd and submitting form)
34-
val authorizationCode =
35-
client.post(
36-
this.authorizationEndpointUrl("default").authenticationRequest(),
37-
mapOf("username" to initialSubject),
38-
).let { authResponse ->
39-
authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code")
40-
}
36+
val tokenResponseBeforeRefresh = this.runAuthCodeFlow(issuerId, initialSubject)
4137

42-
authorizationCode.shouldNotBeNull()
43-
44-
// Token Request based on authorization code
45-
val tokenResponseBeforeRefresh =
38+
// make token request with the refresh_token grant
39+
val refreshToken = checkNotNull(tokenResponseBeforeRefresh.refreshToken)
40+
val refreshTokenResponse =
4641
client.tokenRequest(
4742
this.tokenEndpointUrl(issuerId),
4843
mapOf(
49-
"grant_type" to GrantType.AUTHORIZATION_CODE.value,
50-
"code" to authorizationCode,
44+
"grant_type" to GrantType.REFRESH_TOKEN.value,
45+
"refresh_token" to refreshToken,
5146
"client_id" to "id",
5247
"client_secret" to "secret",
53-
"redirect_uri" to "http://something",
5448
),
5549
).toTokenResponse()
5650

57-
tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject
58-
tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject
51+
refreshTokenResponse.asClue {
52+
it shouldBeValidFor GrantType.REFRESH_TOKEN
53+
it.refreshToken shouldBe tokenResponseBeforeRefresh.refreshToken
54+
it.idToken!! shouldNotBe tokenResponseBeforeRefresh.idToken!!
55+
it.accessToken!! shouldNotBe tokenResponseBeforeRefresh.accessToken!!
56+
it.accessToken should verifyWith(issuerId, this)
57+
it.idToken should verifyWith(issuerId, this)
58+
59+
it.idToken.subject shouldBe initialSubject
60+
it.idToken.audience shouldBe tokenResponseBeforeRefresh.idToken.audience
61+
it.accessToken.subject shouldBe initialSubject
62+
}
63+
}
64+
}
65+
66+
@Test
67+
fun `refresh_token grant should return tokens with same subject as authorization code grant, even when refreshtoken is rotated`() {
68+
withMockOAuth2Server(OAuth2Config(rotateRefreshToken = true)) {
69+
val initialSubject = "yolo"
70+
val issuerId = "idprovider"
71+
72+
val tokenResponseBeforeRefresh = this.runAuthCodeFlow(issuerId, initialSubject)
5973

6074
// make token request with the refresh_token grant
6175
val refreshToken = checkNotNull(tokenResponseBeforeRefresh.refreshToken)
@@ -70,16 +84,13 @@ class RefreshTokenGrantIntegrationTest {
7084
),
7185
).toTokenResponse()
7286

73-
refreshTokenResponse shouldBeValidFor GrantType.REFRESH_TOKEN
74-
refreshTokenResponse.refreshToken shouldBe tokenResponseBeforeRefresh.refreshToken
75-
refreshTokenResponse.idToken!! shouldNotBe tokenResponseBeforeRefresh.idToken!!
76-
refreshTokenResponse.accessToken!! shouldNotBe tokenResponseBeforeRefresh.accessToken!!
77-
refreshTokenResponse.accessToken should verifyWith(issuerId, this)
78-
refreshTokenResponse.idToken should verifyWith(issuerId, this)
79-
80-
refreshTokenResponse.idToken.subject shouldBe initialSubject
81-
refreshTokenResponse.idToken.audience shouldBe tokenResponseBeforeRefresh.idToken.audience
82-
refreshTokenResponse.accessToken.subject shouldBe initialSubject
87+
refreshTokenResponse.asClue {
88+
it shouldBeValidFor GrantType.REFRESH_TOKEN
89+
it.refreshToken shouldNotBe tokenResponseBeforeRefresh.refreshToken
90+
it.idToken?.subject shouldBe initialSubject
91+
it.idToken?.audience shouldBe tokenResponseBeforeRefresh.idToken?.audience
92+
it.accessToken?.subject shouldBe initialSubject
93+
}
8394
}
8495
}
8596

@@ -126,4 +137,38 @@ class RefreshTokenGrantIntegrationTest {
126137
refreshTokenResponse.idToken should verifyWith(issuerId, this)
127138
}
128139
}
140+
141+
private fun MockOAuth2Server.runAuthCodeFlow(
142+
issuerId: String,
143+
initialSubject: String,
144+
): ParsedTokenResponse {
145+
// Authenticate using Authorization Code Flow
146+
// simulate user interaction by doing the auth request as a post (instead of get with user punching username/pwd and submitting form)
147+
val authorizationCode =
148+
client.post(
149+
this.authorizationEndpointUrl("default").authenticationRequest(),
150+
mapOf("username" to initialSubject),
151+
).let { authResponse ->
152+
authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code")
153+
}
154+
155+
authorizationCode.shouldNotBeNull()
156+
157+
// Token Request based on authorization code
158+
val tokenResponseBeforeRefresh =
159+
client.tokenRequest(
160+
this.tokenEndpointUrl(issuerId),
161+
mapOf(
162+
"grant_type" to GrantType.AUTHORIZATION_CODE.value,
163+
"code" to authorizationCode,
164+
"client_id" to "id",
165+
"client_secret" to "secret",
166+
"redirect_uri" to "http://something",
167+
),
168+
).toTokenResponse()
169+
170+
tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject
171+
tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject
172+
return tokenResponseBeforeRefresh
173+
}
129174
}

0 commit comments

Comments
 (0)