From 684200ed3c2eff9078b020d7a0f598a6c505c124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Tr=C3=B8en?= Date: Wed, 5 Jul 2023 19:34:48 +0200 Subject: [PATCH 1/2] feat(revocation): add revocation endpoint supporting refresh tokens --- .../oauth2/extensions/HttpUrlExtensions.kt | 1 + .../mock/oauth2/grant/RefreshTokenManager.kt | 1 + .../oauth2/http/OAuth2HttpRequestHandler.kt | 20 ++++ .../oauth2/e2e/RevocationIntegrationTest.kt | 91 +++++++++++++++++++ .../http/OAuth2HttpRequestHandlerTest.kt | 8 ++ 5 files changed, 121 insertions(+) create mode 100644 src/test/kotlin/no/nav/security/mock/oauth2/e2e/RevocationIntegrationTest.kt diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt b/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt index ff5f295b..184ecbe7 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt @@ -20,6 +20,7 @@ object OAuth2Endpoints { const val AUTHORIZATION = "/authorize" const val TOKEN = "/token" const val END_SESSION = "/endsession" + const val REVOKE = "/revoke" const val JWKS = "/jwks" const val USER_INFO = "/userinfo" const val INTROSPECT = "/introspect" diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManager.kt b/src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManager.kt index c02b750b..177f02d3 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManager.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/grant/RefreshTokenManager.kt @@ -11,6 +11,7 @@ internal data class RefreshTokenManager( private val cache: MutableMap = HashMap(), ) { operator fun get(refreshToken: RefreshToken) = cache[refreshToken] + fun remove(refreshToken: RefreshToken) = cache.remove(refreshToken) fun refreshToken(tokenCallback: OAuth2TokenCallback, nonce: String?): RefreshToken { val jti = UUID.randomUUID().toString() diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandler.kt b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandler.kt index 4ee7b0bf..41bab6e7 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandler.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandler.kt @@ -44,6 +44,9 @@ import java.net.URLEncoder import java.nio.charset.Charset import java.util.concurrent.BlockingQueue import java.util.concurrent.LinkedBlockingQueue +import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE +import no.nav.security.mock.oauth2.extensions.clientAuthentication +import no.nav.security.mock.oauth2.grant.RefreshToken private val log = KotlinLogging.logger {} @@ -83,6 +86,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) { authorization() token() endSession() + revocation(refreshTokenManager) userInfo(config.tokenProvider) introspect(config.tokenProvider) preflight() @@ -129,6 +133,22 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) { } ?: html("logged out") } + private fun Route.Builder.revocation(refreshTokenManager: RefreshTokenManager) = post(REVOKE) { + log.debug("handle revocation request $it") + val auth = it.asNimbusHTTPRequest().clientAuthentication() + when (val hint = it.formParameters.get("token_type_hint")) { + "refresh_token" -> { + val token = it.formParameters.get("token") as RefreshToken + refreshTokenManager.remove(token) + } + else -> throw OAuth2Exception( + ErrorObject("unsupported_token_type", "unsupported token type: $hint", 400), + "unsupported token type: $hint" + ) + } + OAuth2HttpResponse(status = 200, body = "ok") + } + private fun Route.Builder.token() = apply { get(TOKEN) { OAuth2HttpResponse(status = 405, body = "unsupported method") diff --git a/src/test/kotlin/no/nav/security/mock/oauth2/e2e/RevocationIntegrationTest.kt b/src/test/kotlin/no/nav/security/mock/oauth2/e2e/RevocationIntegrationTest.kt new file mode 100644 index 00000000..4e18e633 --- /dev/null +++ b/src/test/kotlin/no/nav/security/mock/oauth2/e2e/RevocationIntegrationTest.kt @@ -0,0 +1,91 @@ +package no.nav.security.mock.oauth2.e2e + +import com.nimbusds.oauth2.sdk.GrantType +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import no.nav.security.mock.oauth2.MockOAuth2Server +import no.nav.security.mock.oauth2.grant.RefreshToken +import no.nav.security.mock.oauth2.testutils.ParsedTokenResponse +import no.nav.security.mock.oauth2.testutils.authenticationRequest +import no.nav.security.mock.oauth2.testutils.client +import no.nav.security.mock.oauth2.testutils.post +import no.nav.security.mock.oauth2.testutils.subject +import no.nav.security.mock.oauth2.testutils.toTokenResponse +import no.nav.security.mock.oauth2.testutils.tokenRequest +import no.nav.security.mock.oauth2.withMockOAuth2Server +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import org.junit.jupiter.api.Test + +class RevocationIntegrationTest { + private val client: OkHttpClient = client() + private val initialSubject = "yolo" + private val issuerId = "idprovider" + + @Test + fun `revocation request with refresh_token should should remove refresh token`() { + withMockOAuth2Server { + val tokenResponseBeforeRefresh = login() + tokenResponseBeforeRefresh.idToken?.subject shouldBe initialSubject + tokenResponseBeforeRefresh.accessToken?.subject shouldBe initialSubject + + var refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken) + refreshTokenResponse.accessToken?.subject shouldBe initialSubject + val refreshToken = checkNotNull(refreshTokenResponse.refreshToken) + val revocationResponse = client.post( + this.url("/default/revoke"), + mapOf( + "client_id" to "id", + "client_secret" to "secret", + "token" to refreshToken, + "token_type_hint" to "refresh_token" + ), + ) + revocationResponse.code shouldBe 200 + + refreshTokenResponse = refresh(tokenResponseBeforeRefresh.refreshToken) + refreshTokenResponse.accessToken?.subject shouldNotBe initialSubject + } + } + + private fun MockOAuth2Server.login(): 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 + return client.tokenRequest( + this.tokenEndpointUrl(issuerId), + mapOf( + "grant_type" to GrantType.AUTHORIZATION_CODE.value, + "code" to authorizationCode, + "client_id" to "id", + "client_secret" to "secret", + "scope" to "openid", + "redirect_uri" to "http://something", + ), + ).toTokenResponse() + } + + private fun MockOAuth2Server.refresh(token: RefreshToken?): ParsedTokenResponse { + // make token request with the refresh_token grant + val refreshToken = checkNotNull(token) + return client.tokenRequest( + this.tokenEndpointUrl(issuerId), + mapOf( + "grant_type" to GrantType.REFRESH_TOKEN.value, + "refresh_token" to refreshToken, + "client_id" to "id", + "client_secret" to "secret", + ), + ).toTokenResponse() + } +} diff --git a/src/test/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandlerTest.kt b/src/test/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandlerTest.kt index 957e0852..b1086932 100644 --- a/src/test/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandlerTest.kt +++ b/src/test/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandlerTest.kt @@ -18,6 +18,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream +import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE internal class OAuth2HttpRequestHandlerTest { @@ -54,6 +55,13 @@ internal class OAuth2HttpRequestHandlerTest { expectedResponse = OAuth2HttpResponse(status = 200), ), request(path = "/issuer1$END_SESSION", method = "GET", expectedResponse = OAuth2HttpResponse(status = 200)), + request( + path = "/issuer1$REVOKE", + method = "POST", + headers = Headers.headersOf("Content-Type", "application/x-www-form-urlencoded"), + body = "client_id=client&client_secret=secret&token=token&token_type_hint=refresh_token", + expectedResponse = OAuth2HttpResponse(status = 200) + ), request(path = "/issuer1$USER_INFO", method = "GET", headers = bearerTokenHeader("issuer1"), expectedResponse = OAuth2HttpResponse(status = 200)), request(path = "/issuer1$DEBUGGER", method = "GET", expectedResponse = OAuth2HttpResponse(status = 200)), request( From 14d9886fec901793adb5ca43bf6a5913c513f4eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tommy=20Tr=C3=B8en?= Date: Thu, 6 Jul 2023 09:32:32 +0200 Subject: [PATCH 2/2] feat(revocation): add revocation endpoint to well-known --- .../no/nav/security/mock/oauth2/MockOAuth2Server.kt | 10 ++++++++++ .../mock/oauth2/extensions/HttpUrlExtensions.kt | 3 +++ .../nav/security/mock/oauth2/http/OAuth2HttpRequest.kt | 2 ++ .../security/mock/oauth2/http/OAuth2HttpResponse.kt | 2 ++ .../mock/oauth2/extensions/HttpUrlExtensionsTest.kt | 1 + 5 files changed, 18 insertions(+) diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt b/src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt index b403a023..77ce3ace 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt @@ -36,6 +36,7 @@ import java.net.URI import java.time.Duration import java.util.UUID import java.util.concurrent.TimeUnit +import no.nav.security.mock.oauth2.extensions.toRevocationEndpointUrl private val log = KotlinLogging.logger { } @@ -194,6 +195,15 @@ open class MockOAuth2Server( */ fun endSessionEndpointUrl(issuerId: String): HttpUrl = url(issuerId).toEndSessionEndpointUrl() + /** + * Returns the authorization server's `revocation_endpoint` for the given [issuerId]. + * + * E.g. `http://localhost:8080/some-issuer/revoke`. + * + * @param issuerId The path or identifier for the issuer. + */ + fun revocationEndpointUrl(issuerId: String): HttpUrl = url(issuerId).toRevocationEndpointUrl() + /** * Returns the authorization server's `userinfo_endpoint` for the given [issuerId]. * diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt b/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt index 184ecbe7..9ba0960a 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt @@ -10,6 +10,7 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN +import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.REVOKE import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.TOKEN import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.USER_INFO import okhttp3.HttpUrl @@ -33,6 +34,7 @@ object OAuth2Endpoints { AUTHORIZATION, TOKEN, END_SESSION, + REVOKE, JWKS, USER_INFO, INTROSPECT, @@ -55,6 +57,7 @@ fun HttpUrl.toOAuth2AuthorizationServerMetadataUrl() = issuer(OAUTH2_WELL_KNOWN) fun HttpUrl.toWellKnownUrl(): HttpUrl = issuer(OIDC_WELL_KNOWN) fun HttpUrl.toAuthorizationEndpointUrl(): HttpUrl = issuer(AUTHORIZATION) fun HttpUrl.toEndSessionEndpointUrl(): HttpUrl = issuer(END_SESSION) +fun HttpUrl.toRevocationEndpointUrl(): HttpUrl = issuer(REVOKE) fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN) fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS) fun HttpUrl.toIssuerUrl(): HttpUrl = issuer() diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequest.kt b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequest.kt index 49a61185..2e0c9e8f 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequest.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequest.kt @@ -12,6 +12,7 @@ import no.nav.security.mock.oauth2.extensions.toEndSessionEndpointUrl import no.nav.security.mock.oauth2.extensions.toIntrospectUrl import no.nav.security.mock.oauth2.extensions.toIssuerUrl import no.nav.security.mock.oauth2.extensions.toJwksUrl +import no.nav.security.mock.oauth2.extensions.toRevocationEndpointUrl import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl import no.nav.security.mock.oauth2.extensions.toUserInfoUrl import no.nav.security.mock.oauth2.grant.TokenExchangeGrant @@ -73,6 +74,7 @@ data class OAuth2HttpRequest( authorizationEndpoint = this.proxyAwareUrl().toAuthorizationEndpointUrl().toString(), tokenEndpoint = this.proxyAwareUrl().toTokenEndpointUrl().toString(), endSessionEndpoint = this.proxyAwareUrl().toEndSessionEndpointUrl().toString(), + revocationEndpoint = this.proxyAwareUrl().toRevocationEndpointUrl().toString(), introspectionEndpoint = this.proxyAwareUrl().toIntrospectUrl().toString(), jwksUri = this.proxyAwareUrl().toJwksUrl().toString(), userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString(), diff --git a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpResponse.kt b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpResponse.kt index c9836498..6d48ce89 100644 --- a/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpResponse.kt +++ b/src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpResponse.kt @@ -28,6 +28,8 @@ data class WellKnown( val authorizationEndpoint: String, @JsonProperty("end_session_endpoint") val endSessionEndpoint: String, + @JsonProperty("revocation_endpoint") + val revocationEndpoint: String, @JsonProperty("token_endpoint") val tokenEndpoint: String, @JsonProperty("userinfo_endpoint") diff --git a/src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt b/src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt index 4c87aec6..513a8432 100644 --- a/src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt +++ b/src/test/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensionsTest.kt @@ -28,6 +28,7 @@ internal class HttpUrlExtensionsTest { httpUrl.toDebuggerCallbackUrl() shouldBe "$baseUrl/debugger/callback".toHttpUrl() httpUrl.toDebuggerUrl() shouldBe "$baseUrl/debugger".toHttpUrl() httpUrl.toEndSessionEndpointUrl() shouldBe "$baseUrl/endsession".toHttpUrl() + httpUrl.toRevocationEndpointUrl() shouldBe "$baseUrl/revoke".toHttpUrl() httpUrl.toJwksUrl() shouldBe "$baseUrl/jwks".toHttpUrl() } }