Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(revocation): add revocation endpoint supporting refresh tokens #504

Merged
merged 3 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 { }

Expand Down Expand Up @@ -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].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +21,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"
Expand All @@ -32,6 +34,7 @@ object OAuth2Endpoints {
AUTHORIZATION,
TOKEN,
END_SESSION,
REVOKE,
JWKS,
USER_INFO,
INTROSPECT,
Expand All @@ -54,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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal data class RefreshTokenManager(
private val cache: MutableMap<RefreshToken, OAuth2TokenCallback> = 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -83,6 +86,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
authorization()
token()
endSession()
revocation(refreshTokenManager)
userInfo(config.tokenProvider)
introspect(config.tokenProvider)
preflight()
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(
Expand Down