diff --git a/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.js b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.js new file mode 100644 index 000000000000..727aaad76309 --- /dev/null +++ b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.js @@ -0,0 +1,73 @@ +const assert = require("assert"); +const cas = require("../../cas.js"); + +async function assertPageUrlDoesNotContain(page, url) { + const result = await page.url(); + assert(!result.includes(url)); +} + +async function verifyLogoutWithIdTokenHint(clientId, redirectUrl, page) { + let params = "grant_type=client_credentials&"; + params += `scope=${encodeURIComponent("openid")}`; + const tokenUrl = `https://localhost:8443/cas/oidc/token?${params}`; + await cas.log(`Calling ${tokenUrl} for client id ${clientId} to obtain an ID token...`); + + const idToken = await cas.doPost(tokenUrl, "", { + "Content-Type": "application/json", + "Authorization": `Basic ${btoa(`${clientId}:secret`)}` + }, async (res) => { + await cas.log(res.data.id_token); + return res.data.id_token; + }, (error) => { + throw `Operation failed: ${error}`; + }); + + let logoutUrl = "https://localhost:8443/cas/oidc/oidcLogout"; + logoutUrl += `?post_logout_redirect_uri=${redirectUrl}`; + logoutUrl += "&state=1234567890"; + logoutUrl += `&client_id=${clientId}`; + logoutUrl += `&id_token_hint=${idToken}`; + + // XXX Unable to handle custom URL schemes. This call causes a + // net::ERR_ABORTED error, which results in response === null. + const response = await cas.goto(page, logoutUrl, 1); + await cas.sleep(1000); + await cas.logPage(page); + if (response !== null) { + await cas.log(`${response.status()} ${response.statusText()}`); + } + + // We can't assert anything positive about the resulting URL, but we can at + // least assert that the redirect URL is NOT treated as a relative path. + await assertPageUrlDoesNotContain(page, `/${redirectUrl}`); + + await cas.gotoLogout(page, "https://localhost:9859/anything/oidc&client_id=whatever"); + await cas.sleep(1000); + await cas.assertPageUrlStartsWith(page, "https://localhost:8443/cas/logout"); +} + +(async () => { + const casService = "https://localhost:9859/anything/cas"; + const browser = await cas.newBrowser(cas.browserOptions()); + const page = await cas.newPage(browser); + let response = await cas.gotoLogout(page, casService); + await cas.sleep(1000); + await cas.logPage(page); + await cas.log(`${response.status()} ${response.statusText()}`); + assert(response.ok()); + await cas.assertPageUrl(page, casService); + + response = await cas.goto(page, await page.url()); + await cas.sleep(1000); + await cas.logPage(page); + await cas.log(`${response.status()} ${response.statusText()}`); + assert(response.status() === 200); + await cas.assertPageUrl(page, casService); + + const customSchemeRedirect = "custom://localhost:9859/anything/oidc"; + await verifyLogoutWithIdTokenHint("client", customSchemeRedirect, page); + + const customIssuerService = "custom://localhost:9859/anything/customissuer"; + await verifyLogoutWithIdTokenHint("customclient", customIssuerService, page); + await cas.closeBrowser(browser); +})(); diff --git a/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.json b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.json new file mode 100644 index 000000000000..8e04bc209e13 --- /dev/null +++ b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/script.json @@ -0,0 +1,22 @@ +{ + "dependencies": "oidc", + "conditions": { + "docker": "true" + }, + "properties": [ + "--cas.server.name=https://localhost:8443", + "--cas.server.prefix=${cas.server.name}/cas", + + "--cas.logout.follow-service-redirects=true", + + "--cas.authn.oidc.core.issuer=https://localhost:8443/cas/oidc", + "--cas.authn.oidc.jwks.file-system.jwks-file=file:${#systemProperties['java.io.tmpdir']}/keystore.jwks", + + "--cas.authn.oidc.discovery.claims=sub,name", + "--cas.authn.oidc.discovery.scopes=openid,profile,email", + + "--cas.service-registry.core.init-from-json=true", + "--cas.service-registry.json.location=file:${PWD}/ci/tests/puppeteer/scenarios/${SCENARIO}/services" + ], + "initScript": "${PWD}/ci/tests/httpbin/run-httpbin-server.sh" +} diff --git a/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/CAS-2.json b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/CAS-2.json new file mode 100644 index 000000000000..588e2b5e70d2 --- /dev/null +++ b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/CAS-2.json @@ -0,0 +1,6 @@ +{ + "@class" : "org.apereo.cas.services.CasRegisteredService", + "serviceId" : "^https://localhost:9859/anything/cas.*", + "name" : "CAS", + "id" : 2 +} diff --git a/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-1.json b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-1.json new file mode 100644 index 000000000000..1f8225c25ddf --- /dev/null +++ b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-1.json @@ -0,0 +1,10 @@ +{ + "@class" : "org.apereo.cas.services.OidcRegisteredService", + "clientId": "client", + "clientSecret": "secret", + "serviceId" : "^(https|custom)://localhost:9859/anything/oidc.*", + "name": "OIDC", + "id": 1, + "jwtAccessToken": "true", + "scopes" : [ "java.util.HashSet", ["openid"]] +} diff --git a/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-3.json b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-3.json new file mode 100644 index 000000000000..a4f85a9ddc03 --- /dev/null +++ b/ci/tests/puppeteer/scenarios/oidc-logout-custom-url-scheme/services/OIDC-3.json @@ -0,0 +1,11 @@ +{ + "@class" : "org.apereo.cas.services.OidcRegisteredService", + "clientId": "customclient", + "clientSecret": "secret", + "idTokenIssuer": "https://localhost:8443", + "serviceId" : "^(https|custom)://localhost:9859/anything/customissuer.*", + "name": "OIDC", + "id": 3, + "jwtAccessToken": "true", + "scopes" : [ "java.util.HashSet", ["openid"]] +} diff --git a/support/cas-server-support-oidc-core-api/src/main/java/org/apereo/cas/oidc/web/controllers/logout/OidcLogoutEndpointController.java b/support/cas-server-support-oidc-core-api/src/main/java/org/apereo/cas/oidc/web/controllers/logout/OidcLogoutEndpointController.java index ed3b0967550d..260907d389da 100644 --- a/support/cas-server-support-oidc-core-api/src/main/java/org/apereo/cas/oidc/web/controllers/logout/OidcLogoutEndpointController.java +++ b/support/cas-server-support-oidc-core-api/src/main/java/org/apereo/cas/oidc/web/controllers/logout/OidcLogoutEndpointController.java @@ -2,6 +2,7 @@ import org.apereo.cas.CasProtocolConstants; import org.apereo.cas.audit.AuditableContext; +import org.apereo.cas.configuration.CasConfigurationProperties; import org.apereo.cas.logout.slo.SingleLogoutUrl; import org.apereo.cas.oidc.OidcConfigurationContext; import org.apereo.cas.oidc.OidcConstants; @@ -45,14 +46,18 @@ @Tag(name = "OpenID Connect") public class OidcLogoutEndpointController extends BaseOidcController { + private final CasConfigurationProperties casProperties; + private final UrlValidator urlValidator; private final OidcPostLogoutRedirectUrlMatcher postLogoutRedirectUrlMatcher; - public OidcLogoutEndpointController(final OidcConfigurationContext context, + public OidcLogoutEndpointController(final CasConfigurationProperties casProperties, + final OidcConfigurationContext context, final OidcPostLogoutRedirectUrlMatcher postLogoutRedirectUrlMatcher, final UrlValidator urlValidator) { super(context); + this.casProperties = casProperties; this.urlValidator = urlValidator; this.postLogoutRedirectUrlMatcher = postLogoutRedirectUrlMatcher; } @@ -186,8 +191,18 @@ protected ResponseEntity executeLogoutRedirect(final Optional state, state.ifPresent(st -> builder.queryParam(OAuth20Constants.STATE, st)); clientId.ifPresent(id -> builder.queryParam(OAuth20Constants.CLIENT_ID, id)); val logoutUrl = builder.build().toUriString(); - LOGGER.debug("Final logout redirect URL is [{}]", logoutUrl); - WebUtils.putLogoutRedirectUrl(request, logoutUrl); + + // Redirect through the callbackAuthorize endpoint, to prevent custom URL schemes from being interpreted as + // relative paths. Otherwise, redirects intended for, e.g., custom://post_logout_redirect_uri would + // instead be redirected to https://localhost:8443/cas/custom://post_logout_redirect_uri. + val finalBuilder = UriComponentsBuilder.fromUriString( + OAuth20Utils.casOAuthCallbackUrl(casProperties.getServer().getPrefix())); + finalBuilder.queryParam("redirect_uri", logoutUrl); + clientId.ifPresent(id -> finalBuilder.queryParam(OAuth20Constants.CLIENT_ID, id)); + val finalUrl = finalBuilder.encode().build().toUriString(); + + LOGGER.error("Final logout redirect URL is [{}]", finalUrl); + WebUtils.putLogoutRedirectUrl(request, finalUrl); }); request.setAttribute("status", HttpStatus.PERMANENT_REDIRECT); request.getServletContext() diff --git a/support/cas-server-support-oidc/src/main/java/org/apereo/cas/config/OidcEndpointsConfiguration.java b/support/cas-server-support-oidc/src/main/java/org/apereo/cas/config/OidcEndpointsConfiguration.java index 0a672db98245..7b3f528713e0 100644 --- a/support/cas-server-support-oidc/src/main/java/org/apereo/cas/config/OidcEndpointsConfiguration.java +++ b/support/cas-server-support-oidc/src/main/java/org/apereo/cas/config/OidcEndpointsConfiguration.java @@ -352,13 +352,14 @@ public OidcPushedAuthorizeEndpointController oidcPushedAuthorizeController( @Bean @ConditionalOnMissingBean(name = "oidcLogoutEndpointController") public OidcLogoutEndpointController oidcLogoutEndpointController( + final CasConfigurationProperties casProperties, @Qualifier(OidcPostLogoutRedirectUrlMatcher.BEAN_NAME_POST_LOGOUT_REDIRECT_URL_MATCHER) final OidcPostLogoutRedirectUrlMatcher postLogoutRedirectUrlMatcher, @Qualifier(UrlValidator.BEAN_NAME) final UrlValidator urlValidator, @Qualifier(OidcConfigurationContext.BEAN_NAME) final OidcConfigurationContext oidcConfigurationContext) { - return new OidcLogoutEndpointController(oidcConfigurationContext, + return new OidcLogoutEndpointController(casProperties, oidcConfigurationContext, postLogoutRedirectUrlMatcher, urlValidator); }