Skip to content

Commit c651b64

Browse files
authored
feat(revocation): add revocation endpoint supporting refresh tokens (#504)
* add revocation endpoint to well-known document
1 parent dbc12d8 commit c651b64

File tree

9 files changed

+139
-0
lines changed

9 files changed

+139
-0
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import java.net.URI
3636
import java.time.Duration
3737
import java.util.UUID
3838
import java.util.concurrent.TimeUnit
39+
import no.nav.security.mock.oauth2.extensions.toRevocationEndpointUrl
3940

4041
private val log = KotlinLogging.logger { }
4142

@@ -194,6 +195,15 @@ open class MockOAuth2Server(
194195
*/
195196
fun endSessionEndpointUrl(issuerId: String): HttpUrl = url(issuerId).toEndSessionEndpointUrl()
196197

198+
/**
199+
* Returns the authorization server's `revocation_endpoint` for the given [issuerId].
200+
*
201+
* E.g. `http://localhost:8080/some-issuer/revoke`.
202+
*
203+
* @param issuerId The path or identifier for the issuer.
204+
*/
205+
fun revocationEndpointUrl(issuerId: String): HttpUrl = url(issuerId).toRevocationEndpointUrl()
206+
197207
/**
198208
* Returns the authorization server's `userinfo_endpoint` for the given [issuerId].
199209
*

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
1010
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS
1111
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN
1212
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN
13+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE
1314
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.TOKEN
1415
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO
1516
import okhttp3.HttpUrl
@@ -20,6 +21,7 @@ object OAuth2Endpoints {
2021
const val AUTHORIZATION = "/authorize"
2122
const val TOKEN = "/token"
2223
const val END_SESSION = "/endsession"
24+
const val REVOKE = "/revoke"
2325
const val JWKS = "/jwks"
2426
const val USER_INFO = "/userinfo"
2527
const val INTROSPECT = "/introspect"
@@ -32,6 +34,7 @@ object OAuth2Endpoints {
3234
AUTHORIZATION,
3335
TOKEN,
3436
END_SESSION,
37+
REVOKE,
3538
JWKS,
3639
USER_INFO,
3740
INTROSPECT,
@@ -54,6 +57,7 @@ fun HttpUrl.toOAuth2AuthorizationServerMetadataUrl() = issuer(OAUTH2_WELL_KNOWN)
5457
fun HttpUrl.toWellKnownUrl(): HttpUrl = issuer(OIDC_WELL_KNOWN)
5558
fun HttpUrl.toAuthorizationEndpointUrl(): HttpUrl = issuer(AUTHORIZATION)
5659
fun HttpUrl.toEndSessionEndpointUrl(): HttpUrl = issuer(END_SESSION)
60+
fun HttpUrl.toRevocationEndpointUrl(): HttpUrl = issuer(REVOKE)
5761
fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN)
5862
fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS)
5963
fun HttpUrl.toIssuerUrl(): HttpUrl = issuer()

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ internal data class RefreshTokenManager(
1111
private val cache: MutableMap<RefreshToken, OAuth2TokenCallback> = HashMap(),
1212
) {
1313
operator fun get(refreshToken: RefreshToken) = cache[refreshToken]
14+
fun remove(refreshToken: RefreshToken) = cache.remove(refreshToken)
1415

1516
fun refreshToken(tokenCallback: OAuth2TokenCallback, nonce: String?): RefreshToken {
1617
val jti = UUID.randomUUID().toString()

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import no.nav.security.mock.oauth2.extensions.toEndSessionEndpointUrl
1212
import no.nav.security.mock.oauth2.extensions.toIntrospectUrl
1313
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
1414
import no.nav.security.mock.oauth2.extensions.toJwksUrl
15+
import no.nav.security.mock.oauth2.extensions.toRevocationEndpointUrl
1516
import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl
1617
import no.nav.security.mock.oauth2.extensions.toUserInfoUrl
1718
import no.nav.security.mock.oauth2.grant.TokenExchangeGrant
@@ -73,6 +74,7 @@ data class OAuth2HttpRequest(
7374
authorizationEndpoint = this.proxyAwareUrl().toAuthorizationEndpointUrl().toString(),
7475
tokenEndpoint = this.proxyAwareUrl().toTokenEndpointUrl().toString(),
7576
endSessionEndpoint = this.proxyAwareUrl().toEndSessionEndpointUrl().toString(),
77+
revocationEndpoint = this.proxyAwareUrl().toRevocationEndpointUrl().toString(),
7678
introspectionEndpoint = this.proxyAwareUrl().toIntrospectUrl().toString(),
7779
jwksUri = this.proxyAwareUrl().toJwksUrl().toString(),
7880
userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString(),

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ import java.net.URLEncoder
4444
import java.nio.charset.Charset
4545
import java.util.concurrent.BlockingQueue
4646
import java.util.concurrent.LinkedBlockingQueue
47+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE
48+
import no.nav.security.mock.oauth2.extensions.clientAuthentication
49+
import no.nav.security.mock.oauth2.grant.RefreshToken
4750

4851
private val log = KotlinLogging.logger {}
4952

@@ -83,6 +86,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
8386
authorization()
8487
token()
8588
endSession()
89+
revocation(refreshTokenManager)
8690
userInfo(config.tokenProvider)
8791
introspect(config.tokenProvider)
8892
preflight()
@@ -129,6 +133,22 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
129133
} ?: html("logged out")
130134
}
131135

136+
private fun Route.Builder.revocation(refreshTokenManager: RefreshTokenManager) = post(REVOKE) {
137+
log.debug("handle revocation request $it")
138+
val auth = it.asNimbusHTTPRequest().clientAuthentication()
139+
when (val hint = it.formParameters.get("token_type_hint")) {
140+
"refresh_token" -> {
141+
val token = it.formParameters.get("token") as RefreshToken
142+
refreshTokenManager.remove(token)
143+
}
144+
else -> throw OAuth2Exception(
145+
ErrorObject("unsupported_token_type", "unsupported token type: $hint", 400),
146+
"unsupported token type: $hint"
147+
)
148+
}
149+
OAuth2HttpResponse(status = 200, body = "ok")
150+
}
151+
132152
private fun Route.Builder.token() = apply {
133153
get(TOKEN) {
134154
OAuth2HttpResponse(status = 405, body = "unsupported method")

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ data class WellKnown(
2828
val authorizationEndpoint: String,
2929
@JsonProperty("end_session_endpoint")
3030
val endSessionEndpoint: String,
31+
@JsonProperty("revocation_endpoint")
32+
val revocationEndpoint: String,
3133
@JsonProperty("token_endpoint")
3234
val tokenEndpoint: String,
3335
@JsonProperty("userinfo_endpoint")
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package no.nav.security.mock.oauth2.e2e
2+
3+
import com.nimbusds.oauth2.sdk.GrantType
4+
import io.kotest.matchers.nulls.shouldNotBeNull
5+
import io.kotest.matchers.shouldBe
6+
import io.kotest.matchers.shouldNotBe
7+
import no.nav.security.mock.oauth2.MockOAuth2Server
8+
import no.nav.security.mock.oauth2.grant.RefreshToken
9+
import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse
10+
import no.nav.security.mock.oauth2.testutils.authenticationRequest
11+
import no.nav.security.mock.oauth2.testutils.client
12+
import no.nav.security.mock.oauth2.testutils.post
13+
import no.nav.security.mock.oauth2.testutils.subject
14+
import no.nav.security.mock.oauth2.testutils.toTokenResponse
15+
import no.nav.security.mock.oauth2.testutils.tokenRequest
16+
import no.nav.security.mock.oauth2.withMockOAuth2Server
17+
import okhttp3.HttpUrl.Companion.toHttpUrl
18+
import okhttp3.OkHttpClient
19+
import org.junit.jupiter.api.Test
20+
21+
class RevocationIntegrationTest {
22+
private val client: OkHttpClient = client()
23+
private val initialSubject = "yolo"
24+
private val issuerId = "idprovider"
25+
26+
@Test
27+
fun `revocation request with refresh_token should should remove refresh token`() {
28+
withMockOAuth2Server {
29+
val tokenResponseBeforeRefresh = login()
30+
tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject
31+
tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject
32+
33+
var refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken)
34+
refreshTokenResponse.accessToken?.subject shouldBe initialSubject
35+
val refreshToken = checkNotNull(refreshTokenResponse.refreshToken)
36+
val revocationResponse = client.post(
37+
this.url("/default/revoke"),
38+
mapOf(
39+
"client_id" to "id",
40+
"client_secret" to "secret",
41+
"token" to refreshToken,
42+
"token_type_hint" to "refresh_token"
43+
),
44+
)
45+
revocationResponse.code shouldBe 200
46+
47+
refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken)
48+
refreshTokenResponse.accessToken?.subject shouldNotBe initialSubject
49+
}
50+
}
51+
52+
private fun MockOAuth2Server.login(): ParsedTokenResponse {
53+
// Authenticate using Authorization Code Flow
54+
// simulate user interaction by doing the auth request as a post (instead of get with user punching username/pwd and submitting form)
55+
val authorizationCode = client.post(
56+
this.authorizationEndpointUrl("default").authenticationRequest(),
57+
mapOf("username" to initialSubject),
58+
).let { authResponse ->
59+
authResponse.headers["location"]?.toHttpUrl()?.queryParameter("code")
60+
}
61+
62+
authorizationCode.shouldNotBeNull()
63+
64+
// Token Request based on authorization code
65+
return client.tokenRequest(
66+
this.tokenEndpointUrl(issuerId),
67+
mapOf(
68+
"grant_type" to GrantType.AUTHORIZATION_CODE.value,
69+
"code" to authorizationCode,
70+
"client_id" to "id",
71+
"client_secret" to "secret",
72+
"scope" to "openid",
73+
"redirect_uri" to "http://something",
74+
),
75+
).toTokenResponse()
76+
}
77+
78+
private fun MockOAuth2Server.refresh(token: RefreshToken?): ParsedTokenResponse {
79+
// make token request with the refresh_token grant
80+
val refreshToken = checkNotNull(token)
81+
return client.tokenRequest(
82+
this.tokenEndpointUrl(issuerId),
83+
mapOf(
84+
"grant_type" to GrantType.REFRESH_TOKEN.value,
85+
"refresh_token" to refreshToken,
86+
"client_id" to "id",
87+
"client_secret" to "secret",
88+
),
89+
).toTokenResponse()
90+
}
91+
}

src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal class HttpUrlExtensionsTest {
2828
httpUrl.toDebuggerCallbackUrl() shouldBe "$baseUrl/debugger/callback".toHttpUrl()
2929
httpUrl.toDebuggerUrl() shouldBe "$baseUrl/debugger".toHttpUrl()
3030
httpUrl.toEndSessionEndpointUrl() shouldBe "$baseUrl/endsession".toHttpUrl()
31+
httpUrl.toRevocationEndpointUrl() shouldBe "$baseUrl/revoke".toHttpUrl()
3132
httpUrl.toJwksUrl() shouldBe "$baseUrl/jwks".toHttpUrl()
3233
}
3334
}

src/test/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandlerTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.junit.jupiter.params.ParameterizedTest
1818
import org.junit.jupiter.params.provider.Arguments
1919
import org.junit.jupiter.params.provider.MethodSource
2020
import java.util.stream.Stream
21+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE
2122

2223
internal class OAuth2HttpRequestHandlerTest {
2324

@@ -54,6 +55,13 @@ internal class OAuth2HttpRequestHandlerTest {
5455
expectedResponse = OAuth2HttpResponse(status = 200),
5556
),
5657
request(path = "/issuer1$END_SESSION", method = "GET", expectedResponse = OAuth2HttpResponse(status = 200)),
58+
request(
59+
path = "/issuer1$REVOKE",
60+
method = "POST",
61+
headers = Headers.headersOf("Content-Type", "application/x-www-form-urlencoded"),
62+
body = "client_id=client&client_secret=secret&token=token&token_type_hint=refresh_token",
63+
expectedResponse = OAuth2HttpResponse(status = 200)
64+
),
5765
request(path = "/issuer1$USER_INFO", method = "GET", headers = bearerTokenHeader("issuer1"), expectedResponse = OAuth2HttpResponse(status = 200)),
5866
request(path = "/issuer1$DEBUGGER", method = "GET", expectedResponse = OAuth2HttpResponse(status = 200)),
5967
request(

0 commit comments

Comments
 (0)