Skip to content

Commit 5ab851e

Browse files
committed
Add per-SP WAYF remember-choice core
Introduce the backend core for remembering a user's chosen IdP on a per-SP basis, gated behind two independent opt-ins: an EngineBlock-wide config flag and the SP's own Manage metadata coin. This replaces, for opted-in SPs, the existing single global "remember choice" cookie with a signed-per-entry cookie keyed by SP entityId, so different SPs can have independent (and independently expiring) remembered IdPs. Only true/"1" values for the SP metadata coin enable the feature for that SP; any other value (including "false" or "-1") leaves it disabled, so a malformed Manage value can never silently turn on choice-remembering behaviour. - Coins/ServiceProvider/PushMetadataAssembler gain the wayf_remember_choice coin (metadata:coin:wayf_remember_choice), defaulting to false and parsed with strict true-only semantics. - RememberedIdpCookie encodes/decodes the "rememberedidps" cookie (deflate + base64 JSON, one {idp, expires} entry per SP entityId), validates entry shape defensively, drops expired entries on every read, and prunes down to a configurable maximum (keeping the highest-expiry entries) only when a new entry is added. - DiContainerRuntime/EngineBlockBootstrapper bridge the new cookie service and the per-SP feature flag into the legacy Corto layer. - SingleSignOn bypasses WAYF using a remembered IdP when the SP has opted in and the choice is still valid for one of the candidate IdPs, preserving the existing zero/one-candidate and SSO-notification shortcuts; the legacy global "rememberchoice" cookie path is now gated off for SPs using the new per-SP mode instead of double-firing. - ContinueToIdp persists a checked "remember this choice" selection into the cookie and normalizes/prunes it on every request regardless of whether this SP is enrolled, so stale entries age out naturally. - The WAYF view model, Twig templates and rememberChoice.js are wired so the per-SP checkbox submits a hidden form field instead of writing a client-side cookie, leaving the legacy global-cookie behaviour untouched for SPs that haven't opted in. Refs: #2065
1 parent 42dbc9f commit 5ab851e

29 files changed

Lines changed: 1527 additions & 16 deletions

File tree

‎config/packages/parameters.yml.dist‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ parameters:
182182

183183
## Allow users to save their selected IdP and then auto-select it on returning visits.
184184
wayf.remember_choice: false
185+
## Allow SPs to opt in to remembered IdP choices scoped per SP through Manage metadata.
186+
wayf.remember_choice_per_idp: false
187+
## Lifetime for remembered IdP choices scoped per SP, in seconds.
188+
wayf.remember_choice_per_idp_lifetime: 7776000
189+
## Maximum number of remembered IdP choice entries to retain per SP.
190+
wayf.remember_choice_per_idp_max: 16
185191

186192
## Toggle the default IdP quick link banner on the WAYF.
187193
wayf.display_default_idp_banner_on_wayf: true

‎config/services/services.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
autowire: true
1616
autoconfigure: true
1717
arguments:
18+
$rememberChoicePerIdp: '%wayf.remember_choice_per_idp%'
1819
$preferredIdpEntityIds: '%wayf.preferred_idp_entity_ids%'
1920
tags:
2021
- { name: kernel.event_subscriber }
@@ -173,6 +174,15 @@ services:
173174

174175
OpenConext\EngineBlock\Service\CookieService:
175176

177+
OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie:
178+
autowire: true
179+
arguments:
180+
$lifetime: '%wayf.remember_choice_per_idp_lifetime%'
181+
$maxEntries: '%wayf.remember_choice_per_idp_max%'
182+
$cookieDomain: '%hostname%'
183+
$cookiePath: '%cookie.path%'
184+
$secure: '%cookie.secure%'
185+
176186
OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory:
177187
arguments:
178188
- '@engineblock.compat.metadata.definitions'

‎library/EngineBlock/Corto/Module/Service/ContinueToIdp.php‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
2020
use OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory;
2121
use OpenConext\EngineBlock\Metadata\X509\KeyPairFactory;
22+
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
2223
use Symfony\Component\HttpFoundation\Request;
2324
use Twig\Environment;
2425

@@ -113,9 +114,62 @@ public function serve($serviceName, Request $httpRequest)
113114
$log->info('Raw HTTP request', array('http_request' => (string) $application->getHttpRequest()));
114115
}
115116

117+
$this->_persistRememberedChoice($sp, $idp, $httpRequest->request->get('rememberChoice'));
118+
116119
$this->_server->sendAuthenticationRequest($request, $selectedIdp);
117120
}
118121

122+
/**
123+
* Normalizes the 'rememberedidps' cookie and, when the checkbox was checked and the per-SP
124+
* remember-choice feature is enabled for both EngineBlock and the SP, persists the (SP, IdP)
125+
* choice in it.
126+
*
127+
* @param ServiceProvider $sp
128+
* @param mixed $idp
129+
* @param string|null $rememberChoice
130+
*/
131+
private function _persistRememberedChoice(ServiceProvider $sp, $idp, $rememberChoice)
132+
{
133+
$application = EngineBlock_ApplicationSingleton::getInstance();
134+
$container = $application->getDiContainer();
135+
$runtime = $application->getDiContainerRuntime();
136+
$log = $this->_server->getLogger();
137+
138+
$rememberedIdpCookie = $runtime->rememberedIdpCookie;
139+
140+
$raw = $container->getSymfonyRequest()->cookies->get(RememberedIdpCookie::NAME);
141+
$entries = $rememberedIdpCookie->loadValidEntries($raw, $log);
142+
143+
if ($rememberChoice !== '1') {
144+
return;
145+
}
146+
147+
if (!RememberedIdpCookie::isEnabledForServiceProvider(
148+
$container->getRememberChoice() === true,
149+
$runtime->isRememberChoicePerIdpEnabled(),
150+
$sp
151+
)) {
152+
return;
153+
}
154+
155+
$result = $rememberedIdpCookie->add($entries, $sp->entityId, $idp->entityId);
156+
$rememberedIdpCookie->write($result['entries'], $result['expires']);
157+
158+
$log->info(sprintf(
159+
'WAYF-remember-my-choice set for SP %s and IdP %s and expiry %d',
160+
$sp->entityId,
161+
$idp->entityId,
162+
$result['expires']
163+
));
164+
165+
if ($result['dropped'] > 0) {
166+
$log->info(sprintf(
167+
'WAYF-remember-my-choice %d entities dropped from cookie',
168+
$result['dropped']
169+
));
170+
}
171+
}
172+
119173
/**
120174
* @param EngineBlock_Corto_ProxyServer $proxyServer
121175
* @return ServiceProvider

‎library/EngineBlock/Corto/Module/Service/SingleSignOn.php‎

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
use OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory;
2121
use OpenConext\EngineBlock\Metadata\Discovery;
2222
use OpenConext\EngineBlock\Metadata\X509\KeyPairFactory;
23+
use OpenConext\EngineBlock\Service\Wayf\RememberedIdpCookie;
2324
use OpenConext\EngineBlock\Service\Wayf\WayfIdp;
25+
use OpenConext\EngineBlockBundle\Bridge\DiContainerRuntime;
2426
use OpenConext\EngineBlockBundle\Service\DiscoverySelectionService;
2527
use SAML2\AuthnRequest;
2628
use SAML2\Constants;
@@ -221,7 +223,17 @@ public function serve($serviceName, Request $httpRequest)
221223
}
222224

223225
// Auto-select IdP when 'wayf.rememberChoice' feature is enabled and is allowed for the current request
224-
if (($container->getRememberChoice() === true) && !($request->getForceAuthn() || $request->isDebugRequest())) {
226+
$runtime = $application->getDiContainerRuntime();
227+
if ($this->_sendAuthenticationRequestForRememberedIdpPerServiceProvider($request, $sp, $candidateIDPs)) {
228+
return;
229+
}
230+
231+
// Auto-select IdP when 'wayf.rememberChoice' feature is enabled and is allowed for the current request,
232+
// and the per-SP remembered-choice feature is not enabled.
233+
if ($container->getRememberChoice() === true
234+
&& $runtime->isRememberChoicePerIdpEnabled() === false
235+
&& !($request->getForceAuthn() || $request->isDebugRequest())
236+
) {
225237
$cookies = $container->getSymfonyRequest()->cookies->all();
226238
if (array_key_exists('rememberchoice', $cookies)) {
227239
$remembered = json_decode($cookies['rememberchoice']);
@@ -457,6 +469,64 @@ protected function _limitScopeToRememberedIdp(
457469
return $scopedIdps;
458470
}
459471

472+
/**
473+
* @param string[] $candidateIDPs
474+
*/
475+
protected function _sendAuthenticationRequestForRememberedIdpPerServiceProvider(
476+
EngineBlock_Saml2_AuthnRequestAnnotationDecorator $request,
477+
ServiceProvider $sp,
478+
array $candidateIDPs
479+
): bool {
480+
$application = EngineBlock_ApplicationSingleton::getInstance();
481+
$container = $application->getDiContainer();
482+
$runtime = $application->getDiContainerRuntime();
483+
$log = $this->_server->getLogger();
484+
485+
if (!RememberedIdpCookie::isEnabledForServiceProvider(
486+
$container->getRememberChoice() === true,
487+
$runtime->isRememberChoicePerIdpEnabled(),
488+
$sp
489+
)
490+
|| $request->getForceAuthn()
491+
|| $request->isDebugRequest()
492+
) {
493+
return false;
494+
}
495+
496+
$rememberedIdp = $this->_findRememberedIdpForServiceProvider($runtime, $sp->entityId, $candidateIDPs);
497+
if ($rememberedIdp === null) {
498+
return false;
499+
}
500+
501+
$log->info(sprintf(
502+
'WAYF-remember-my-choice WAYF skipped for %s because of preselected %s',
503+
$sp->entityId,
504+
$rememberedIdp
505+
));
506+
$this->_server->sendAuthenticationRequest($request, $rememberedIdp);
507+
508+
return true;
509+
}
510+
511+
/**
512+
* @param string[] $candidateIdpEntityIds
513+
*/
514+
protected function _findRememberedIdpForServiceProvider(
515+
DiContainerRuntime $runtime,
516+
string $serviceProviderEntityId,
517+
array $candidateIdpEntityIds
518+
): ?string {
519+
$rememberedIdpCookie = $runtime->rememberedIdpCookie;
520+
$log = $this->_server->getLogger();
521+
522+
$raw = EngineBlock_ApplicationSingleton::getInstance()->getDiContainer()
523+
->getSymfonyRequest()->cookies->get(RememberedIdpCookie::NAME);
524+
525+
$entries = $rememberedIdpCookie->loadValidEntries($raw, $log);
526+
527+
return $rememberedIdpCookie->find($entries, $serviceProviderEntityId, $candidateIdpEntityIds);
528+
}
529+
460530
protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $request, array $candidateIdpEntityIds)
461531
{
462532
// Post to the 'continueToIdp' service
@@ -482,6 +552,22 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
482552

483553
$diContainerRuntime = $application->getDiContainerRuntime();
484554

555+
// The per-SP remember-choice mode is only authoritative when the global feature, the
556+
// EngineBlock-wide per-SP flag and the SP's own metadata coin are all enabled. Otherwise
557+
// the legacy global 'rememberchoice' cookie behaviour (below) applies unchanged.
558+
$isRememberChoicePerIdpEnabled = $diContainerRuntime->isRememberChoicePerIdpEnabled();
559+
$rememberChoicePerIdp = RememberedIdpCookie::isEnabledForServiceProvider(
560+
$container->getRememberChoice() === true,
561+
$isRememberChoicePerIdpEnabled,
562+
$serviceProvider
563+
);
564+
565+
// While per-SP mode is active, an opted-out SP must not fall back to the legacy global
566+
// cookie feature: rememberChoiceFeature mirrors rememberChoicePerIdp in that case.
567+
$rememberChoiceFeature = $isRememberChoicePerIdpEnabled
568+
? $rememberChoicePerIdp
569+
: $container->getRememberChoice();
570+
485571
$output = $diContainerRuntime->wayfRenderer->render(
486572
idpList: $idpList,
487573
preferredIdpEntityIds: $diContainerRuntime->getPreferredIdpEntityIds(),
@@ -491,10 +577,11 @@ protected function _showWayf(EngineBlock_Saml2_AuthnRequestAnnotationDecorator $
491577
shouldDisplayBanner: (bool) $container->shouldDisplayDefaultIdpBannerOnWayf(),
492578
backLink: $container->isUiOptionReturnToSpActive(),
493579
cutoffPoint: $container->getCutoffPointForShowingUnfilteredIdps(),
494-
rememberChoice: $container->getRememberChoice(),
580+
rememberChoice: $rememberChoiceFeature,
495581
showRequestAccess: $serviceProvider->getCoins()->displayUnconnectedIdpsWayf(),
496582
requestId: $request->getId(),
497583
serviceProvider: $serviceProvider,
584+
rememberChoicePerIdp: $rememberChoicePerIdp,
498585
);
499586
$this->_server->sendOutput($output);
500587
}

‎src/OpenConext/EngineBlock/Metadata/Coins.php‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ public static function createForServiceProvider(
4545
$additionalLogging,
4646
$signatureMethod,
4747
$stepupForceAuthn,
48-
$collabEnabled
48+
$collabEnabled,
49+
bool $wayfRememberChoice = false
4950
) {
5051
return new self([
5152
'isConsentRequired' => $isConsentRequired,
@@ -64,6 +65,7 @@ public static function createForServiceProvider(
6465
'stepupRequireLoa' => $stepupRequireLoa,
6566
'stepupForceAuthn' => $stepupForceAuthn,
6667
'collabEnabled' => $collabEnabled,
68+
'wayfRememberChoice' => $wayfRememberChoice,
6769
]);
6870
}
6971

@@ -153,6 +155,11 @@ public function displayUnconnectedIdpsWayf()
153155
return $this->getValue('displayUnconnectedIdpsWayf', false);
154156
}
155157

158+
public function wayfRememberChoice(): bool
159+
{
160+
return $this->getValue('wayfRememberChoice', false) === true;
161+
}
162+
156163
public function termsOfServiceUrl()
157164
{
158165
return $this->getValue('termsOfServiceUrl');

‎src/OpenConext/EngineBlock/Metadata/Entity/Assembler/PushMetadataAssembler.php‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ private function assembleSp(stdClass $connection)
220220
$properties += $this->setPathFromObjectBool([$connection, 'metadata:coin:transparant_issuer'], 'isTransparentIssuer');
221221
$properties += $this->setPathFromObjectBool([$connection, 'metadata:coin:trusted_proxy'], 'isTrustedProxy');
222222
$properties += $this->setPathFromObjectBool([$connection, 'metadata:coin:display_unconnected_idps_wayf'], 'displayUnconnectedIdpsWayf');
223+
$properties += $this->setPathFromObjectBool(
224+
[$connection, 'metadata:coin:wayf_remember_choice'],
225+
'wayfRememberChoice',
226+
strictTrueOnly: true
227+
);
223228

224229
$properties += $this->assembleIsConsentRequired($connection);
225230

@@ -450,12 +455,15 @@ private function setPathFromObjectArray(array $from, string $to): array
450455
return array($to => $reference);
451456
}
452457

453-
private function setPathFromObjectBool(array $from, string $to): array
458+
private function setPathFromObjectBool(array $from, string $to, bool $strictTrueOnly = false): array
454459
{
455460
$reference = $this->getValueFromPath($from);
456461
if (is_null($reference)) {
457462
return array($to => null);
458463
}
464+
if ($strictTrueOnly) {
465+
return array($to => $reference === true || $reference === '1');
466+
}
459467
return array($to => (bool)$reference);
460468
}
461469

‎src/OpenConext/EngineBlock/Metadata/Entity/ServiceProvider.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ public function __construct(
152152
?bool $stepupAllowNoToken = null,
153153
?string $stepupRequireLoa = null,
154154
bool $stepupForceAuthn = false,
155-
bool $collabEnabled = false
155+
bool $collabEnabled = false,
156+
bool $wayfRememberChoice = false
156157
) {
157158
if (is_null($mdui)) {
158159
$mdui = Mdui::emptyMdui();
@@ -211,7 +212,8 @@ public function __construct(
211212
$additionalLogging,
212213
$signatureMethod,
213214
$stepupForceAuthn,
214-
$collabEnabled
215+
$collabEnabled,
216+
$wayfRememberChoice
215217
);
216218
}
217219

‎src/OpenConext/EngineBlock/Service/CookieService.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,26 @@ public function setCookie(
4747
return setcookie($name, $value, $expire, $path, $domain, $secure, $httpOnly);
4848
}
4949

50+
public function setCookieWithSameSite(
51+
string $name,
52+
$value,
53+
int $expire,
54+
string $path,
55+
string $domain,
56+
bool $secure = true,
57+
bool $httpOnly = true,
58+
string $sameSite = 'None'
59+
): bool {
60+
return setcookie($name, $value, [
61+
'expires' => $expire,
62+
'path' => $path,
63+
'domain' => $domain,
64+
'secure' => $secure,
65+
'httponly' => $httpOnly,
66+
'samesite' => $sameSite,
67+
]);
68+
}
69+
5070
/**
5171
* Clears the cookie for the specified name, path and domain.
5272
*
@@ -66,4 +86,22 @@ public function clearCookie(
6686
): bool {
6787
return setcookie($name, '', -1, $path, $domain, $secure, $httpOnly);
6888
}
89+
90+
public function clearCookieWithSameSite(
91+
string $name,
92+
string $path,
93+
string $domain,
94+
bool $secure = true,
95+
bool $httpOnly = true,
96+
string $sameSite = 'None'
97+
): bool {
98+
return setcookie($name, '', [
99+
'expires' => -1,
100+
'path' => $path,
101+
'domain' => $domain,
102+
'secure' => $secure,
103+
'httponly' => $httpOnly,
104+
'samesite' => $sameSite,
105+
]);
106+
}
69107
}

0 commit comments

Comments
 (0)