Skip to content

Commit 6e1ff7a

Browse files
authored
Implement per-SP WAYF remember-choice core (#2065) (#2091)
* 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 * Tidy rememberedidps cookie and feature-flag configuration # If applied, this commit will... Make the rememberedidps cookie host-only and rename the per-SP WAYF remember-choice flag to match the feature_enable_ naming convention. # Why is this change needed? Prior to this change, the rememberedidps cookie was scoped to the configured hostname domain and shared with subdomains, and the wayf.remember_choice_per_idp parameter used a bare wayf.* key while sibling feature flags in the same file follow a feature_enable_* convention. Both were raised in review. # How does it address the issue? This change passes an empty cookie domain so PHP emits a host-only cookie, and renames the parameter key in parameters.yml.dist and the service binding in services.yml. PHP-side property and method names are unchanged. # Provide links to any relevant tickets, articles or other resources #2091 * Encapsulate and harden rememberedidps cookie reading/writing # If applied, this commit will... Add RememberedIdpCookie::loadValidEntriesFromRequest() for both read call sites, log an error instead of silently succeeding when a cookie write fails, and suppress the resulting PHPMD complexity warning. # Why is this change needed? Prior to this change, ContinueToIdp and SingleSignOn both plucked the rememberedidps cookie off the Symfony request directly, duplicating the same lines and leaking cookie-reading details into legacy Corto code; a failed cookie write was still logged as a successful remember-choice update. Both were raised in review. # How does it address the issue? This change adds loadValidEntriesFromRequest() on RememberedIdpCookie and updates both call sites to use it, checks the cookie write result and logs an error on failure (covered by a regression test), and adds a targeted PHPMD suppression for the resulting class complexity, matching the existing pattern used for DiContainerRuntime. # Provide links to any relevant tickets, articles or other resources #2091 * Bound decompression of the client-controlled rememberedidps cookie # If applied, this commit will... Add an explicit 64KB max_length bound to the gzinflate() call used to decode the rememberedidps cookie. # Why is this change needed? Prior to this change, inflate() called gzinflate() with no explicit output-size bound, which was flagged in review: a small, highly compressible attacker-crafted cookie should never be allowed to decompress into an unbounded buffer. # How does it address the issue? This change passes a 65536-byte max_length to gzinflate(), so an oversized or maliciously compressed cookie is rejected as invalid instead of being decompressed without limit. # Provide links to any relevant tickets, articles or other resources #2091 * Simplify remember-choice condition and property access in SSO flow # If applied, this commit will... Extract two multi-line boolean conditions in SingleSignOn into named local variables, and make rememberChoicePerIdp a public property on DiContainerRuntime instead of a private property plus getter. # Why is this change needed? Prior to this change, both conditions mixed a multi-line function call or comparison with trailing || continuation lines indented deeper than the if(, and rememberChoicePerIdp was the only property on this readonly class guarded by a getter while every sibling property is accessed directly. Both were raised in review. # How does it address the issue? This change assigns each condition to a clearly named boolean ($legacyRememberChoiceAllowed, $notEligibleForRememberedIdp) with no change in behavior, and makes the property public, updating the four call sites and two unit test assertions to read $runtime->rememberChoicePerIdp directly. # Provide links to any relevant tickets, articles or other resources #2091 * Enforce and document remember-choice mode mutual exclusivity # If applied, this commit will... Make the per-SP and global WAYF remember-my-choice modes mutually exclusive, and make the cookie removal page available in per-SP mode. # Why is this change needed? Prior to this change, the per-SP remember-my-choice mode only worked when the global wayf.remember_choice was also enabled, so both features were intertwined, and the business wants exactly one of the two modes active. Additionally, /authentication/idp/remove-cookies only worked when wayf.remember_choice was true, so a deployment using only the per-SP mode had no cookie removal page at all and the rememberedidps cookie was never listed on it. # How does it address the issue? This change makes the per-SP mode independent of wayf.remember_choice and adds a compiler pass that fails the container build when both flags are true, so a misconfiguration is caught during cache:clear/cache:warmup at deploy time. The cookie removal page is now shown when either mode is enabled and lists the rememberedidps cookie, clearing it through RememberedIdpCookie so the configured path and domain are honoured. The modes, their parameters, the exclusivity rule and the new page behaviour are documented in docs/wayf_remember_choice.md, parameters.yml.dist, the theme README and the CHANGELOG. # Provide links to any relevant tickets, articles or other resources #2091 * Route the per-SP remember-choice flag through FeatureConfiguration # If applied, this commit will... Route feature_enable_wayf_remember_choice_per_idp through the existing FeatureConfiguration mechanism instead of injecting the raw parameter directly, reuse it for the mutual-exclusivity check in the compiler pass, and guard against the key being unknown to a given FeatureConfiguration instance. # Why is this change needed? Prior to this change, the per-SP remember-choice flag was wired into EngineBlockBootstrapper as a plain boolean parameter, and RememberChoiceModeCompilerPass duplicated its own ad hoc boolean parsing for the mutual-exclusivity check, which did not follow the generic custom_features/FeatureConfiguration mechanism already used by every other feature toggle. EngineBlockBootstrapper also runs on every request via kernel.request, and several existing functional tests override FeatureConfiguration with a partial mock containing only their own scenario's keys, so calling isEnabled() directly on the new key threw when it was missing, causing a 302 redirect instead of the expected 404 on seven unrelated controller tests. # How does it address the issue? This change registers eb.feature_enable_wayf_remember_choice_per_idp in custom_features, moves its parameter declaration into the feature settings section of parameters.yml.dist, and has EngineBlockBootstrapper resolve it via FeatureConfigurationInterface::hasFeature()/isEnabled() rather than a raw parameter binding, treating a missing key as disabled instead of throwing. RememberChoiceModeCompilerPass now builds a small FeatureConfiguration instance from the two resolved parameters and checks both through isEnabled(). Public constants, the exception type and message stay unchanged, and the bootstrapper's unit tests are updated/extended to cover feature resolution including the missing-key case. # Provide links to any relevant tickets, articles or other resources #2091 * Require the zlib extension explicitly # If applied, this commit will... Move the ext-zlib declaration from require-dev to require in composer.json. # Why is this change needed? Prior to this change, ext-zlib was only declared as a dev dependency, but RememberedIdpCookie uses gzdeflate()/gzinflate() directly in production code, which was raised in review. # How does it address the issue? This change moves ext-zlib to the require section so Composer enforces it as a hard production dependency, and refreshes composer.lock's content hash and platform metadata accordingly. # Provide links to any relevant tickets, articles or other resources #2091
1 parent d94160f commit 6e1ff7a

42 files changed

Lines changed: 2010 additions & 27 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ on the EngineBlock wiki.
1313

1414
## Unreleased
1515

16+
Features:
17+
18+
* Added a per-SP "remember my choice" mode for the WAYF, enabled with
19+
`feature_enable_wayf_remember_choice_per_idp` and the SP metadata coin `coin:wayf_remember_choice`. See
20+
`docs/wayf_remember_choice.md`.
21+
* The per-SP mode and the existing global mode (`wayf.remember_choice`) are mutually exclusive. EngineBlock refuses to
22+
build its container (for example during `cache:clear` or `cache:warmup`) when both are `true`. Enable only one.
23+
* The per-SP mode does not require `wayf.remember_choice`. Leave `wayf.remember_choice` set to `false` when enabling it.
24+
* The cookie removal page `/authentication/idp/remove-cookies` is now also available in the per-SP mode and lists the
25+
`rememberedidps` cookie. Before, it was only available when `wayf.remember_choice` was `true`.
26+
1627
Updated:
1728

1829
* Updated Composer and Yarn (theme and Cypress) dependencies within their current version constraints to resolve open Dependabot alerts. (#2121)

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"ext-mbstring": "*",
2323
"ext-openssl": "*",
2424
"ext-pdo": "*",
25+
"ext-zlib": "*",
2526
"beberlei/assert": "^3.3.3",
2627
"composer/package-versions-deprecated": "^1.11",
2728
"doctrine/dbal": "^4.3.3",
@@ -54,7 +55,6 @@
5455
"twig/twig": "^3.22.0"
5556
},
5657
"require-dev": {
57-
"ext-zlib": "*",
5858
"behat/behat": "^3.26",
5959
"behat/mink": "^1.12",
6060
"behat/mink-browserkit-driver": "^2.2",

‎composer.lock‎

Lines changed: 3 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config/packages/engineblock_features.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ parameters:
1717
eb.stepup.send_user_attributes: "%feature_stepup_send_user_attributes%"
1818
eb.feature_enable_sram_interrupt: "%feature_enable_sram_interrupt%"
1919
eb.hide_bookmarkable_url: "%feature_hide_bookmarkable_url%"
20+
eb.feature_enable_wayf_remember_choice_per_idp: "%feature_enable_wayf_remember_choice_per_idp%"

‎config/packages/parameters.yml.dist‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,16 @@ parameters:
179179
wayf.cutoff_point_for_showing_unfiltered_idps: 50
180180

181181
## Allow users to save their selected IdP and then auto-select it on returning visits.
182+
## This is the global mode: one remembered IdP for all SPs, stored in a client-side cookie.
183+
## Mutually exclusive with feature_enable_wayf_remember_choice_per_idp: enabling both fails the
184+
## container build. See docs/wayf_remember_choice.md.
182185
wayf.remember_choice: false
186+
## Lifetime for remembered IdP choices scoped per SP, in seconds (7776000 seconds = 90 days).
187+
wayf.remember_choice_per_idp_lifetime: 7776000
188+
## Maximum number of remembered IdP choice entries to retain per SP. Once exceeded, the
189+
## oldest-expiring entries are evicted first; the newly added choice is always kept and no
190+
## error is raised (see RememberedIdpCookie::pruneOverLimit()).
191+
wayf.remember_choice_per_idp_max: 16
183192

184193
## Toggle the default IdP quick link banner on the WAYF.
185194
wayf.display_default_idp_banner_on_wayf: true
@@ -235,6 +244,10 @@ parameters:
235244
feature_stepup_send_user_attributes: false
236245
feature_enable_sram_interrupt: false
237246
feature_hide_bookmarkable_url: false
247+
## Allow SPs to opt in to remembered IdP choices scoped per SP through Manage metadata.
248+
## This is the per-SP mode and replaces the global mode: it does not require wayf.remember_choice.
249+
## Mutually exclusive with wayf.remember_choice: enabling both fails the container build.
250+
feature_enable_wayf_remember_choice_per_idp: false
238251

239252
##########################################################################################
240253
## PROFILE SETTINGS

‎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+
$featureConfiguration: '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration'
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: ''
183+
$cookiePath: '%cookie.path%'
184+
$secure: '%cookie.secure%'
185+
176186
OpenConext\EngineBlock\Metadata\Factory\Factory\ServiceProviderFactory:
177187
arguments:
178188
- '@engineblock.compat.metadata.definitions'

‎docs/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
1. [PHP testing][php-testing]
1111
1. [Javscript testing][js-testing]
1212
1. [Logging][logging]
13+
1. [WAYF remember my choice](wayf_remember_choice.md)
1314

1415
[license]: https://github.com/OpenConext/OpenConext-engineblock/tree/master/docs/LICENSE
1516
[release]: https://github.com/OpenConext/OpenConext-engineblock/tree/master/docs/release_procedure.md

‎docs/sso_session_cookie.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ An authentication with the Identity Provider is successful when the SAML respons
1616
The value of the cookie contains an array of the entity ID('s) of the successfully authenticated Identity Providers.
1717
This cookie will be deleted at `/logout` or will expire according to the set `sso_session_cookie_max_age`.
1818
If the SSO Cookie Session feature is enabled it is also possible to manually delete this cookie at
19-
`/authentication/idp/remove-cookies`. Do note however, that the remove cookies endpoint is only available if
20-
`wayf.remember_choice=true`
19+
`/authentication/idp/remove-cookies`. Do note however, that the remove cookies endpoint is only available if one of the
20+
WAYF remember my choice modes is enabled (`wayf.remember_choice` or
21+
`feature_enable_wayf_remember_choice_per_idp`), see [WAYF remember my choice](wayf_remember_choice.md).
2122

2223
## Configuration of SSO Session Cookie in Engineblock
2324

‎docs/wayf_remember_choice.md‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# WAYF remember my choice
2+
3+
EngineBlock can remember which Identity Provider (IdP) a user picked on the WAYF, so that the user skips the WAYF on a
4+
returning visit. There are two ways to do this. They are **mutually exclusive**: you enable one or the other, never
5+
both.
6+
7+
| | Global mode | Per-SP mode |
8+
|---|---|---|
9+
| Parameter | `wayf.remember_choice` | `feature_enable_wayf_remember_choice_per_idp` |
10+
| Scope of the remembered choice | One IdP for all SPs | One IdP per SP |
11+
| SP opt-in required | No, applies to all SPs | Yes, SP metadata coin `coin:wayf_remember_choice` |
12+
| Cookie name | `rememberchoice` | `rememberedidps` |
13+
| Written by | Browser (JavaScript) | EngineBlock (server side) |
14+
| Cookie lifetime | 365 days | `wayf.remember_choice_per_idp_lifetime` (default 90 days) |
15+
| Cookie attributes | Secure, SameSite=None | Secure, HttpOnly, SameSite=None, scoped to the EngineBlock host |
16+
17+
## Choosing a mode
18+
19+
Enable at most one of the following in `config/packages/parameters.yml`:
20+
21+
# Global mode
22+
wayf.remember_choice: true
23+
feature_enable_wayf_remember_choice_per_idp: false
24+
25+
or
26+
27+
# Per-SP mode
28+
wayf.remember_choice: false
29+
feature_enable_wayf_remember_choice_per_idp: true
30+
31+
If both are `false`, the WAYF has no remember my choice checkbox and EngineBlock never skips the WAYF based on an
32+
earlier choice.
33+
34+
### Both modes enabled is an error
35+
36+
If both parameters are `true`, EngineBlock refuses to build its container and fails with:
37+
38+
The WAYF remember-my-choice modes are mutually exclusive: "wayf.remember_choice" and
39+
"feature_enable_wayf_remember_choice_per_idp" cannot both be true. Enable only one of them.
40+
41+
The check runs when the Symfony container is compiled, so a wrong configuration is caught by `cache:clear` or
42+
`cache:warmup` during deployment, before any user is affected. Fix the configuration by setting one of the two
43+
parameters to `false`.
44+
45+
The per-SP mode does not depend on `wayf.remember_choice`. Earlier development versions required both to be `true`;
46+
that combination is now rejected.
47+
48+
## Global mode
49+
50+
With `wayf.remember_choice: true` the WAYF shows a "remember my choice" checkbox. When the user checks it and picks an
51+
IdP, the browser stores the IdP entity ID in the `rememberchoice` cookie. On the next authentication request,
52+
EngineBlock skips the WAYF and sends the user to that IdP if it is a candidate IdP for the requesting SP. The choice is
53+
shared by all SPs.
54+
55+
The remembered IdP is ignored for `ForceAuthn` and debug requests.
56+
57+
The cookie can be removed on `/authentication/idp/remove-cookies`, see [Removing the cookies](#removing-the-cookies).
58+
59+
## Per-SP mode
60+
61+
With `feature_enable_wayf_remember_choice_per_idp: true` the remembered choice is stored per SP. The feature is
62+
opt-in per SP through Manage metadata:
63+
64+
metadata:coin:wayf_remember_choice: true
65+
66+
Only the boolean `true` (or `"1"`) opts an SP in. An SP without the coin never uses the per-SP cookie, and never falls
67+
back to the global cookie.
68+
69+
When a user checks the checkbox and picks an IdP for an opted-in SP, EngineBlock stores the `(SP, IdP)` pair in the
70+
`rememberedidps` cookie. On the next authentication request from that SP, EngineBlock skips the WAYF if the remembered
71+
IdP is still a candidate IdP. The one-IdP shortcut and the SSO notification shortcut always take precedence, and
72+
`ForceAuthn` and debug requests ignore the remembered IdP.
73+
74+
Related parameters:
75+
76+
# Lifetime of a remembered choice, in seconds (7776000 seconds = 90 days)
77+
wayf.remember_choice_per_idp_lifetime: 7776000
78+
79+
# Maximum number of remembered SPs per cookie. The entries that expire soonest are evicted first.
80+
wayf.remember_choice_per_idp_max: 16
81+
82+
The cookie content is validated strictly. Invalid or expired entries are never used to select an IdP and are removed
83+
from the cookie.
84+
85+
The cookie can be removed on `/authentication/idp/remove-cookies`, see [Removing the cookies](#removing-the-cookies).
86+
87+
## Removing the cookies
88+
89+
The page `/authentication/idp/remove-cookies` lets a user inspect and remove the cookies EngineBlock has set,
90+
including `rememberchoice` and `rememberedidps`. The page is available when either mode is enabled, and returns a 404
91+
when both are disabled.
92+
93+
## Switching modes
94+
95+
Cookies written in one mode are not read in the other. After switching, users have to make their choice once more.
96+
Switching from global to per-SP mode requires that you first set `wayf.remember_choice` to `false`, and then enable
97+
`feature_enable_wayf_remember_choice_per_idp` and the `coin:wayf_remember_choice` coin on the SPs that should
98+
use it.

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

Lines changed: 65 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,73 @@ 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+
* @param ServiceProvider $sp
124+
* @param mixed $idp
125+
* @param string|null $rememberChoice
126+
*/
127+
private function _persistRememberedChoice(ServiceProvider $sp, $idp, $rememberChoice)
128+
{
129+
$application = EngineBlock_ApplicationSingleton::getInstance();
130+
$container = $application->getDiContainer();
131+
$runtime = $application->getDiContainerRuntime();
132+
$log = $this->_server->getLogger();
133+
134+
$rememberedIdpCookie = $runtime->rememberedIdpCookie;
135+
136+
$entries = $rememberedIdpCookie->loadValidEntriesFromRequest($container->getSymfonyRequest(), $log);
137+
138+
if (!$this->_isRememberChoiceChecked($rememberChoice)) {
139+
return;
140+
}
141+
142+
if (!RememberedIdpCookie::isEnabledForServiceProvider(
143+
$runtime->rememberChoicePerIdp,
144+
$sp
145+
)) {
146+
return;
147+
}
148+
149+
$result = $rememberedIdpCookie->add($entries, $sp->entityId, $idp->entityId);
150+
if (!$rememberedIdpCookie->write($result['entries'], $result['expires'])) {
151+
$log->error(sprintf(
152+
'WAYF-remember-my-choice cookie write failed for SP %s and IdP %s',
153+
$sp->entityId,
154+
$idp->entityId
155+
));
156+
157+
return;
158+
}
159+
160+
$log->info(sprintf(
161+
'WAYF-remember-my-choice set for SP %s and IdP %s and expiry %d',
162+
$sp->entityId,
163+
$idp->entityId,
164+
$result['expires']
165+
));
166+
167+
if ($result['dropped'] > 0) {
168+
$log->info(sprintf(
169+
'WAYF-remember-my-choice %d entities dropped from cookie',
170+
$result['dropped']
171+
));
172+
}
173+
}
174+
175+
/**
176+
* @param string|null $rememberChoice
177+
* @return bool
178+
*/
179+
private function _isRememberChoiceChecked($rememberChoice)
180+
{
181+
return $rememberChoice === '1';
182+
}
183+
119184
/**
120185
* @param EngineBlock_Corto_ProxyServer $proxyServer
121186
* @return ServiceProvider

0 commit comments

Comments
 (0)