Skip to content

Commit

Permalink
feat(revocation): add revocation endpoint supporting refresh tokens (#…
Browse files Browse the repository at this point in the history
…504)

* add revocation endpoint to well-known document
  • Loading branch information
tommytroen authored Jul 6, 2023
1 parent dbc12d8 commit c651b64
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 0 deletions.
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

0 comments on commit c651b64

Please sign in to comment.