Skip to content

Commit

Permalink
feat: allow to define logout URL in query parameter
Browse files Browse the repository at this point in the history
JIRA:STL-458
risk:low
  • Loading branch information
sadam21 committed Jun 25, 2024
1 parent f43263e commit b8cb9f0
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.gooddata.oauth2.server

import java.net.URI
import mu.KotlinLogging
import org.springframework.http.HttpRequest
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.security.core.Authentication
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
import org.springframework.security.oauth2.client.registration.ClientRegistration
Expand Down Expand Up @@ -43,14 +43,17 @@ class Auth0LogoutHandler(
override fun onLogoutSuccess(exchange: WebFilterExchange, authentication: Authentication): Mono<Void> =
logout(exchange, authentication)

private fun logoutUrl(request: HttpRequest): Mono<URI> =
private fun logoutUrl(request: ServerHttpRequest): Mono<URI> =
clientRegistrationRepository.findByRegistrationId(request.uri.host)
.map { clientRegistration ->
Pair(clientRegistration, clientRegistration.issuer())
}.filter { (_, issuer) ->
issuer.isAuth0() || issuer.hasCustomDomain()
}.map { (clientRegistration, issuer) ->
buildLogoutUrl(issuer, clientRegistration.clientId, request.uri.baseUrl())
// workaround for STL-458: use URL from 'returnTo' query parameter if provided,
// otherwise use default URL
val returnTo = URI.create(request.returnToQueryParam()) ?: request.uri.baseUrl()
buildLogoutUrl(issuer, clientRegistration.clientId, returnTo)
}.doOnNext { logoutUrl ->
logger.debug { "Auth0 logout URL: $logoutUrl" }
}
Expand All @@ -62,6 +65,7 @@ class Auth0LogoutHandler(
private fun buildLogoutUrl(issuer: URI, clientId: String, returnTo: URI): URI =
UriComponentsBuilder.fromHttpUrl("${issuer}v2/logout")
.queryParam("client_id", clientId)
// https://auth0.com/docs/authenticate/login/logout/redirect-users-after-logout
.queryParam("returnTo", returnTo)
.build()
.toUri()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package com.gooddata.oauth2.server

import mu.KotlinLogging
import org.springframework.http.HttpRequest
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.security.core.Authentication
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken
import org.springframework.security.oauth2.client.registration.ClientRegistration
Expand Down Expand Up @@ -60,17 +60,20 @@ class CognitoLogoutHandler(
override fun onLogoutSuccess(exchange: WebFilterExchange, authentication: Authentication): Mono<Void> =
logout(exchange, authentication)

private fun logoutUrl(request: HttpRequest): Mono<URI> =
private fun logoutUrl(request: ServerHttpRequest): Mono<URI> =
clientRegistrationRepository.findByRegistrationId(request.uri.host)
.map { clientRegistration ->
Pair(clientRegistration, clientRegistration.issuer())
}.filter { (_, issuer) ->
issuer.isCognito() || issuer.hasCustomDomain()
}.map { (clientRegistration) ->
// workaround for STL-458: use URL from 'returnTo' query parameter if provided,
// otherwise use default URL
val returnTo = URI.create(request.returnToQueryParam()) ?: request.uri.baseUrl()
buildLogoutUrl(
clientRegistration.endSessionEndpoint(),
clientRegistration.clientId,
request.uri.baseUrl()
returnTo
)
}.doOnNext { logoutUrl ->
logger.debug { "Cognito logout URL: $logoutUrl" }
Expand All @@ -87,6 +90,7 @@ class CognitoLogoutHandler(
UriComponentsBuilder
.fromUri(endSessionEndpoint)
.queryParam("client_id", clientId)
// https://docs.aws.amazon.com/cognito/latest/developerguide/logout-endpoint.html
.queryParam("logout_uri", logoutUri)
.build()
.toUri()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,17 @@ class JwtAuthenticationLogoutHandler(
.filter { authentication is JwtAuthenticationToken }
.flatMap {
// TODO where should we redirect??
redirectStrategy.sendRedirect(exchange.exchange, URI.create("/"))
redirectStrategy.sendRedirect(
exchange.exchange,
// workaround for STL-458: use URL from 'returnTo' query parameter if provided,
// otherwise use default URL
URI.create(exchange.returnToQueryParam() ?: DEFAULT_REDIRECT_URL)
)
}

companion object {
private const val DEFAULT_REDIRECT_URL = "/"
}
}

internal class JwtAuthenticationLogoutException(originalError: Throwable) : ResponseStatusException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2024 GoodData Corporation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.gooddata.oauth2.server

import org.springframework.security.core.Authentication
import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
import org.springframework.security.web.server.WebFilterExchange
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler
import reactor.core.publisher.Mono
import java.net.URI

/**
* Extends functionality of [OidcClientInitiatedServerLogoutSuccessHandler]. Allows to set the post logout redirect URI
* based on the `returnTo` query parameter from the request.
*
* @param [clientRegistrationRepository] the repository for client registrations
* @param [postLogoutRedirectUri] the default post logout redirect URI,
* see [OidcClientInitiatedServerLogoutSuccessHandler.setPostLogoutRedirectUri]
* @param [defaultLogoutSuccessUrl] the default logout success URL,
* see [OidcClientInitiatedServerLogoutSuccessHandler.setLogoutSuccessUrl]
*
* @see [OidcClientInitiatedServerLogoutSuccessHandler]
*/
class QueryParamOidcClientInitiatedServerLogoutSuccessHandler(
private val clientRegistrationRepository: ReactiveClientRegistrationRepository,
private val postLogoutRedirectUri: String,
private val defaultLogoutSuccessUrl: String,
) : ServerLogoutSuccessHandler {

override fun onLogoutSuccess(exchange: WebFilterExchange, authentication: Authentication): Mono<Void> =
OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)
.apply {
setPostLogoutRedirectUri(postLogoutRedirectUri)
setLogoutSuccessUrl(
// workaround for STL-458: use URL from 'returnTo' query parameter if provided,
// otherwise use default URL
URI.create(exchange.returnToQueryParam() ?: defaultLogoutSuccessUrl)
)
}
.onLogoutSuccess(exchange, authentication)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
package com.gooddata.oauth2.server

import java.net.URI
import java.util.Base64
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
Expand All @@ -42,7 +41,6 @@ import org.springframework.security.oauth2.client.endpoint.OAuth2RefreshTokenGra
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient
import org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeReactiveAuthenticationManager
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler
import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
Expand Down Expand Up @@ -249,10 +247,7 @@ class ServerOAuth2AutoConfiguration {

val logoutSuccessHandler = DelegatingServerLogoutSuccessHandler(
// Order of handlers is important!
OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository).apply {
setPostLogoutRedirectUri("{baseUrl}")
setLogoutSuccessUrl(URI.create("/"))
},
QueryParamOidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository, "{baseUrl}", "/"),
// Keep custom OIDC handlers as last in OIDC handlers
CognitoLogoutHandler(clientRegistrationRepository, cognitoCustomDomain),
Auth0LogoutHandler(clientRegistrationRepository, auth0CustomDomain),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,21 @@
*/
package com.gooddata.oauth2.server

import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.security.web.server.WebFilterExchange
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI

const val RETURN_TO_QUERY_PARAM = "returnTo"

/**
* Get "returnTo" query parameter from exchange request
*/
fun WebFilterExchange.returnToQueryParam(): String? =
exchange.request.returnToQueryParam()

fun ServerHttpRequest.returnToQueryParam(): String? = queryParams.getFirst(RETURN_TO_QUERY_PARAM)

/**
* Build URI from string
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
*/
package com.gooddata.oauth2.server

import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.mock.http.server.reactive.MockServerHttpRequest
import org.springframework.mock.web.server.MockServerWebExchange
import org.springframework.security.web.server.WebFilterExchange
import org.springframework.web.server.WebFilterChain
import strikt.api.expectThat
import strikt.assertions.isEqualTo
import strikt.assertions.isFalse
Expand Down Expand Up @@ -38,6 +43,16 @@ class UriExtensionsTest {
expectThat(uri.isAuth0()).isFalse()
}

@Test
fun `returnToQueryParam should return correct query parameter`() {
val request = MockServerHttpRequest.get("http://localhost?returnTo=urlToReturnTo")
val exchange = MockServerWebExchange.from(request)
val chain = mockk<WebFilterChain>()
val webFilterExchange = WebFilterExchange(exchange, chain)

expectThat(webFilterExchange.returnToQueryParam()).isEqualTo("urlToReturnTo")
}

@Test
fun `valid Cognito issuer`() {
val uri = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abcd1234".toUri()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,27 @@ class UserContextWebFluxTest(
.expectCookie().exists("SPRING_REDIRECT_URI")
}

@Test
fun `filter redirects logout from returnTo query param without cookies`() {
val organization = Organization(
id = ORG_ID,
oauthClientId = "clientId",
oauthClientSecret = "clientSecret",
allowedOrigins = listOf("https://localhost:8443"),
)
mockOrganization(authenticationStoreClient, LOCALHOST, organization)
every { exchange.attributes[OrganizationWebFilter.ORGANIZATION_CACHE_KEY] } returns organization
every { serverSecurityContextRepository.load(any()) } returns Mono.empty()
every { serverSecurityContextRepository.save(any(), null) } returns Mono.empty()
every { clientRegistrationRepository.findByRegistrationId(any()) } returns Mono.empty()

webClient.get().uri("http://localhost/logout?returnTo=/userReturnTo")
.exchange()
.expectStatus().isFound
.expectHeader().location("/userReturnTo")
.expectCookie().exists("SPRING_REDIRECT_URI")
}

@Test
fun `filter redirects logout with cookies`() {
every { exchange.attributes[OrganizationWebFilter.ORGANIZATION_CACHE_KEY] } returns ORGANIZATION
Expand All @@ -465,6 +486,26 @@ class UserContextWebFluxTest(
.expectCookie().exists("SPRING_REDIRECT_URI")
}

@Test
fun `filter redirects logout from returnTo query param with cookies`() {
every { exchange.attributes[OrganizationWebFilter.ORGANIZATION_CACHE_KEY] } returns ORGANIZATION
everyValidSecurityContext()
every { serverSecurityContextRepository.save(any(), null) } returns Mono.empty()
every { clientRegistrationRepository.findByRegistrationId(any()) } returns Mono.empty()
everyValidOrganization()
mockUserByAuthId(authenticationStoreClient, ORG_ID, SUB_CLAIM_VALUE, User(USER_ID))
val authenticationToken = ResourceUtils.resource("oauth2_authentication_token.json").readText()
val authorizedClient = ResourceUtils.resource("simplified_oauth2_authorized_client.json").readText()

webClient.get().uri("http://localhost/logout?returnTo=/userReturnTo")
.cookie(SPRING_SEC_SECURITY_CONTEXT, encodeCookieBlocking(authenticationToken))
.cookie(SPRING_SEC_OAUTH2_AUTHZ_CLIENT, encodeCookieBlocking(authorizedClient))
.exchange()
.expectStatus().isFound
.expectHeader().location("/userReturnTo")
.expectCookie().exists("SPRING_REDIRECT_URI")
}

@Test
fun `POST logout ends with 405`() {
every { exchange.attributes[OrganizationWebFilter.ORGANIZATION_CACHE_KEY] } returns ORGANIZATION
Expand Down

0 comments on commit b8cb9f0

Please sign in to comment.