Skip to content
Draft
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
14 changes: 12 additions & 2 deletions config/reference.php
Original file line number Diff line number Diff line change
Expand Up @@ -1466,8 +1466,6 @@
* monolog?: MonologConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* debug?: DebugConfig,
* web_profiler?: WebProfilerConfig,
* "when@ci"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
Expand All @@ -1494,6 +1492,17 @@
* debug?: DebugConfig,
* web_profiler?: WebProfilerConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
* services?: ServicesConfig,
* framework?: FrameworkConfig,
* security?: SecurityConfig,
* twig?: TwigConfig,
* monolog?: MonologConfig,
* doctrine?: DoctrineConfig,
* doctrine_migrations?: DoctrineMigrationsConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
Expand Down Expand Up @@ -1592,6 +1601,7 @@ public static function config(array $config): array
* @psalm-type RoutesConfig = array{
* "when@ci"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
* ...<string, RouteConfig|ImportConfig|AliasConfig>
* }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use OpenConext\EngineBlockBridge\ResponseFactory;
use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
Expand Down Expand Up @@ -122,9 +123,16 @@ public function __construct(
#[Route(path: '/authentication/idp/single-sign-on/{idpHash}', name: 'authentication_idp_sso_idphash', methods: ['GET', 'POST'])]
public function singleSignOnAction(Request $request, ?string $keyId = null, ?string $idpHash = null)
{
$storedRequest = $this->getStoredSessionRequest($request);
if ($storedRequest !== null) {
return new RedirectResponse($storedRequest);
}

$this->requestValidator->isValid($request);
$this->bindingValidator->isValid($request);

$this->storeRequestToSession($request);

$cortoAdapter = new EngineBlock_Corto_Adapter();

if ($keyId !== null) {
Expand All @@ -136,6 +144,20 @@ public function singleSignOnAction(Request $request, ?string $keyId = null, ?str
return ResponseFactory::fromEngineBlockResponse($this->engineBlockApplicationSingleton->getHttpResponse());
}

private function storeRequestToSession(Request $request): void
{
$request->getSession()->set('eb_last_sso_request_url', $request->getUri());
}

private function getStoredSessionRequest(Request $request): ?string
{
if (!$request->isMethod('GET') || $request->query->has('SAMLRequest')) {
return null;
}

return $request->getSession()->remove('eb_last_sso_request_url');
}

/**
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @throws NotFoundHttpException If the IdP-initiated flow has been disabled by config
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Feature:
In order to prevent users from saving a broken SAML URL as a bookmark
As EngineBlock
I want to hide the SAMLRequest from the browser's address bar
while still recovering gracefully when the user presses the Back button

Background:
Given an EngineBlock instance on "dev.openconext.local"
And no registered SPs
And no registered Idps
And an Identity Provider named "Dummy Idp"
And an Identity Provider named "Second Idp"
And a Service Provider named "Dummy SP"

Scenario: Pressing Back after visiting the WAYF restores the SAML request from the session
When I log in at "Dummy SP"
And I go to Engineblock URL "/authentication/idp/single-sign-on"
Then I should see "Dummy Idp"

Scenario: A GET to the SSO endpoint without an active session shows an error
When I log in at "Dummy SP"
And I lose my session
And I go to Engineblock URL "/authentication/idp/single-sign-on"
Then I should see "The parameter \"SAMLRequest\" is missing on the SAML SSO request"
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
context('hideBookmarkableUrl', () => {
describe('When the URL contains a SAMLRequest parameter', () => {
it('replaces the URL with ?feedback=bookmark', () => {
cy.visit('https://engine.dev.openconext.local/functional-testing/wayf?SAMLRequest=somevalue');
cy.url().should('not.include', 'SAMLRequest');
cy.url().should('include', 'feedback=bookmark');
});
});

describe('When the URL does not contain a SAMLRequest parameter', () => {
it('leaves the URL unchanged', () => {
cy.visit('https://engine.dev.openconext.local/functional-testing/wayf');
cy.url().should('not.include', 'feedback=bookmark');
});

it('leaves other query parameters intact', () => {
cy.visit('https://engine.dev.openconext.local/functional-testing/wayf?connectedIdps=5');
cy.url().should('not.include', 'feedback=bookmark');
cy.url().should('include', 'connectedIdps=5');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/**
* Copyright 2026 SURFnet B.V.
*
* 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
*
* http://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.
*/

namespace OpenConext\EngineBlockBundle\Tests;

use EngineBlock_ApplicationSingleton;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use OpenConext\EngineBlock\Exception\MissingParameterException;
use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface;
use OpenConext\EngineBlock\Service\RequestAccessMailer;
use OpenConext\EngineBlock\Validator\RequestValidator;
use OpenConext\EngineBlockBundle\Configuration\FeatureConfigurationInterface;
use OpenConext\EngineBlockBundle\Controller\IdentityProviderController;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Twig\Environment;

class IdentityProviderControllerTest extends TestCase
{
use MockeryPHPUnitIntegration;

private IdentityProviderController $controller;

protected function setUp(): void
{
$this->controller = new IdentityProviderController(
Mockery::mock(EngineBlock_ApplicationSingleton::class),
Mockery::mock(Environment::class),
Mockery::mock(\Psr\Log\LoggerInterface::class),
Mockery::mock(RequestAccessMailer::class),
Mockery::mock(RequestValidator::class),
Mockery::mock(RequestValidator::class),
Mockery::mock(RequestValidator::class),
Mockery::mock(AuthenticationStateHelperInterface::class),
Mockery::mock(FeatureConfigurationInterface::class)
);
}

#[Test]
public function a_get_without_saml_request_redirects_to_the_stored_session_url(): void
{
$session = new Session(new MockArraySessionStorage());
$session->set('eb_last_sso_request_url', 'https://engine.example.com/authentication/idp/single-sign-on?SAMLRequest=abc');

$request = Request::create('https://engine.example.com/authentication/idp/single-sign-on');
$request->setSession($session);

$response = $this->controller->singleSignOnAction($request);

$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame(
'https://engine.example.com/authentication/idp/single-sign-on?SAMLRequest=abc',
$response->getTargetUrl()
);
}

#[Test]
public function the_stored_session_url_is_consumed_on_redirect_so_it_cannot_be_reused(): void
{
$session = new Session(new MockArraySessionStorage());
$session->set('eb_last_sso_request_url', 'https://engine.example.com/authentication/idp/single-sign-on?SAMLRequest=abc');

$request = Request::create('https://engine.example.com/authentication/idp/single-sign-on');
$request->setSession($session);

$this->controller->singleSignOnAction($request);

$this->assertNull($session->get('eb_last_sso_request_url'));
}

#[Test]
public function a_get_without_saml_request_and_no_stored_session_url_throws_missing_parameter_exception(): void
{
$this->expectException(MissingParameterException::class);

$requestValidator = Mockery::mock(RequestValidator::class);
$requestValidator->shouldReceive('isValid')->andThrow(new MissingParameterException('The parameter "SAMLRequest" is missing'));

$controller = new IdentityProviderController(
Mockery::mock(EngineBlock_ApplicationSingleton::class),
Mockery::mock(Environment::class),
Mockery::mock(\Psr\Log\LoggerInterface::class),
Mockery::mock(RequestAccessMailer::class),
$requestValidator,
Mockery::mock(RequestValidator::class),
Mockery::mock(RequestValidator::class),
Mockery::mock(AuthenticationStateHelperInterface::class),
Mockery::mock(FeatureConfigurationInterface::class)
);

$session = new Session(new MockArraySessionStorage());
$request = Request::create('https://engine.example.com/authentication/idp/single-sign-on');
$request->setSession($session);

$controller->singleSignOnAction($request);
}
}
3 changes: 2 additions & 1 deletion theme/base/javascripts/wayf.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {initializePage} from './page';
import {wayfCallbackAfterLoad} from './handlers';
import {wayfPageSelector} from './selectors';
import {hideBookmarkableUrl} from './wayf/hideBookmarkableUrl';

export function initializeWayf() {
initializePage(wayfPageSelector, wayfCallbackAfterLoad);
initializePage(wayfPageSelector, wayfCallbackAfterLoad, hideBookmarkableUrl);
}
17 changes: 17 additions & 0 deletions theme/base/javascripts/wayf/hideBookmarkableUrl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Replace SAML query parameters in the URL bar with ?feedback=bookmark to prevent broken bookmarks
*/
export const hideBookmarkableUrl = () => {
if (!window.history || !window.history.replaceState) {
return;
}

const url = new URL(window.location.href);
if (!url.searchParams.has('SAMLRequest')) {
return;
}

const cleanUrl = new URL(window.location.pathname, window.location.origin);
cleanUrl.searchParams.set('feedback', 'bookmark');
window.history.replaceState(null, '', cleanUrl.toString());
};