Skip to content

Commit

Permalink
feat(refresh_token): rotate refresh tokens if configured to rotate (#645
Browse files Browse the repository at this point in the history
)

* 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
  • Loading branch information
tommytroen committed Feb 28, 2024
1 parent ac7900d commit fcb2017
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 39 deletions.
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,13 +284,14 @@ add this to your config with preferred `JWS algorithm`:
}
```

| Property | Description |
|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `interactiveLogin` | `true` or `false`, enables login screen when redirecting to server `/authorize` endpoint |
| `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. |
| `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. | |
| `httpServer` | A string identifying the httpserver to use. Must match one of the following enum values: `MockWebServerWrapper` or `NettyWrapper` |
| `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. |
| Property | Description |
|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `interactiveLogin` | `true` or `false`, enables login screen when redirecting to server `/authorize` endpoint |
| `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. |
| `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. | |
| `rotateRefreshToken` | `true` or `false`, setting to true will generate a new unique refresh token when using the `refresh_token` grant. |
| `httpServer` | A string identifying the httpserver to use. Must match one of the following enum values: `MockWebServerWrapper` or `NettyWrapper` |
| `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. |

*From the JSON example above:*

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ data class OAuth2Config
val interactiveLogin: Boolean = false,
val loginPagePath: String? = null,
val staticAssetsPath: String? = null,
val rotateRefreshToken: Boolean = false,
@JsonDeserialize(using = OAuth2TokenProviderDeserializer::class)
val tokenProvider: OAuth2TokenProvider = OAuth2TokenProvider(),
@JsonDeserialize(contentAs = RequestMappingTokenCallback::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ private val log = KotlinLogging.logger {}
internal class RefreshTokenGrantHandler(
private val tokenProvider: OAuth2TokenProvider,
private val refreshTokenManager: RefreshTokenManager,
private val rotateRefreshToken: Boolean = false,
) : GrantHandler {
override fun tokenResponse(
request: OAuth2HttpRequest,
issuerUrl: HttpUrl,
oAuth2TokenCallback: OAuth2TokenCallback,
): OAuth2TokenResponse {
val tokenRequest = request.asNimbusTokenRequest()
val refreshToken = tokenRequest.refreshTokenGrant().refreshToken.value
var refreshToken = tokenRequest.refreshTokenGrant().refreshToken.value
log.debug("issuing token for refreshToken=$refreshToken")
val scope: String? = tokenRequest.scope?.toString()
val refreshTokenCallbackOrDefault = refreshTokenManager[refreshToken] ?: oAuth2TokenCallback
if (rotateRefreshToken) {
refreshToken = refreshTokenManager.rotate(refreshToken, refreshTokenCallbackOrDefault)
}
val idToken: SignedJWT = tokenProvider.idToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault)
val accessToken: SignedJWT = tokenProvider.accessToken(tokenRequest, issuerUrl, refreshTokenCallbackOrDefault)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
import java.util.UUID

typealias RefreshToken = String
typealias Nonce = String

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

fun refreshToken(
tokenCallback: OAuth2TokenCallback,
nonce: String?,
nonce: Nonce? = null,
): RefreshToken {
val jti = UUID.randomUUID().toString()
// added for compatibility with keycloak js client which expects a jwt with nonce
Expand All @@ -25,6 +26,14 @@ internal data class RefreshTokenManager(
return refreshToken
}

fun rotate(
refreshToken: RefreshToken,
fallbackTokenCallback: OAuth2TokenCallback,
): RefreshToken {
val callback = cache.remove(refreshToken) ?: fallbackTokenCallback
return refreshToken(callback)
}

private fun plainJWT(
jti: String,
nonce: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
CLIENT_CREDENTIALS to ClientCredentialsGrantHandler(config.tokenProvider),
JWT_BEARER to JwtBearerGrantHandler(config.tokenProvider),
TOKEN_EXCHANGE to TokenExchangeGrantHandler(config.tokenProvider),
REFRESH_TOKEN to RefreshTokenGrantHandler(config.tokenProvider, refreshTokenManager),
REFRESH_TOKEN to RefreshTokenGrantHandler(config.tokenProvider, refreshTokenManager, config.rotateRefreshToken),
PASSWORD to PasswordGrantHandler(config.tokenProvider),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package no.nav.security.mock.oauth2.e2e

import com.nimbusds.oauth2.sdk.GrantType
import io.kotest.assertions.asClue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import no.nav.security.mock.oauth2.MockOAuth2Server
import no.nav.security.mock.oauth2.OAuth2Config
import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse
import no.nav.security.mock.oauth2.testutils.audience
import no.nav.security.mock.oauth2.testutils.authenticationRequest
import no.nav.security.mock.oauth2.testutils.client
Expand All @@ -24,38 +28,48 @@ class RefreshTokenGrantIntegrationTest {
private val client: OkHttpClient = client()

@Test
fun `token request with refresh_token grant should return id_token and access_token with same subject as authorization code grant`() {
fun `refresh_token grant should return id_token and access_token with same subject as authorization code grant`() {
withMockOAuth2Server {
val initialSubject = "yolo"
val issuerId = "idprovider"

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

authorizationCode.shouldNotBeNull()

// Token Request based on authorization code
val tokenResponseBeforeRefresh =
// make token request with the refresh_token grant
val refreshToken = checkNotNull(tokenResponseBeforeRefresh.refreshToken)
val refreshTokenResponse =
client.tokenRequest(
this.tokenEndpointUrl(issuerId),
mapOf(
"grant_type" to GrantType.AUTHORIZATION_CODE.value,
"code" to authorizationCode,
"grant_type" to GrantType.REFRESH_TOKEN.value,
"refresh_token" to refreshToken,
"client_id" to "id",
"client_secret" to "secret",
"redirect_uri" to "http://something",
),
).toTokenResponse()

tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject
tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject
refreshTokenResponse.asClue {
it shouldBeValidFor GrantType.REFRESH_TOKEN
it.refreshToken shouldBe tokenResponseBeforeRefresh.refreshToken
it.idToken!! shouldNotBe tokenResponseBeforeRefresh.idToken!!
it.accessToken!! shouldNotBe tokenResponseBeforeRefresh.accessToken!!
it.accessToken should verifyWith(issuerId, this)
it.idToken should verifyWith(issuerId, this)

it.idToken.subject shouldBe initialSubject
it.idToken.audience shouldBe tokenResponseBeforeRefresh.idToken.audience
it.accessToken.subject shouldBe initialSubject
}
}
}

@Test
fun `refresh_token grant should return tokens with same subject as authorization code grant, even when refreshtoken is rotated`() {
withMockOAuth2Server(OAuth2Config(rotateRefreshToken = true)) {
val initialSubject = "yolo"
val issuerId = "idprovider"

val tokenResponseBeforeRefresh = this.runAuthCodeFlow(issuerId, initialSubject)

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

refreshTokenResponse shouldBeValidFor GrantType.REFRESH_TOKEN
refreshTokenResponse.refreshToken shouldBe tokenResponseBeforeRefresh.refreshToken
refreshTokenResponse.idToken!! shouldNotBe tokenResponseBeforeRefresh.idToken!!
refreshTokenResponse.accessToken!! shouldNotBe tokenResponseBeforeRefresh.accessToken!!
refreshTokenResponse.accessToken should verifyWith(issuerId, this)
refreshTokenResponse.idToken should verifyWith(issuerId, this)

refreshTokenResponse.idToken.subject shouldBe initialSubject
refreshTokenResponse.idToken.audience shouldBe tokenResponseBeforeRefresh.idToken.audience
refreshTokenResponse.accessToken.subject shouldBe initialSubject
refreshTokenResponse.asClue {
it shouldBeValidFor GrantType.REFRESH_TOKEN
it.refreshToken shouldNotBe tokenResponseBeforeRefresh.refreshToken
it.idToken?.subject shouldBe initialSubject
it.idToken?.audience shouldBe tokenResponseBeforeRefresh.idToken?.audience
it.accessToken?.subject shouldBe initialSubject
}
}
}

Expand Down Expand Up @@ -126,4 +137,38 @@ class RefreshTokenGrantIntegrationTest {
refreshTokenResponse.idToken should verifyWith(issuerId, this)
}
}

private fun MockOAuth2Server.runAuthCodeFlow(
issuerId: String,
initialSubject: String,
): ParsedTokenResponse {
// Authenticate using Authorization Code Flow
// simulate user interaction by doing the auth request as a post (instead of get with user punching username/pwd and submitting form)
val authorizationCode =
client.post(
this.authorizationEndpointUrl("default").authenticationRequest(),
mapOf("username" to initialSubject),
).let { authResponse ->
authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code")
}

authorizationCode.shouldNotBeNull()

// Token Request based on authorization code
val tokenResponseBeforeRefresh =
client.tokenRequest(
this.tokenEndpointUrl(issuerId),
mapOf(
"grant_type" to GrantType.AUTHORIZATION_CODE.value,
"code" to authorizationCode,
"client_id" to "id",
"client_secret" to "secret",
"redirect_uri" to "http://something",
),
).toTokenResponse()

tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject
tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject
return tokenResponseBeforeRefresh
}
}

0 comments on commit fcb2017

Please sign in to comment.