Skip to content

Commit 4e1fbba

Browse files
authored
Feature/issue 1864 optional domain hint (#1984)
* Add coin:azure_domain_hint to append whr= on HTTP-Redirect AuthnRequests (#1864) When coin:azure_domain_hint is set on an IdP, EngineBlock appends a whr=<domain> query parameter to the HTTP-Redirect URL it sends as the AuthnRequest to that IdP. This allows Microsoft Azure / EntraID to skip the account picker for users whose realm is already known. - Add azureDomainHint field + getter to Coins (IdP coins) - Add azureDomainHint constructor param to IdentityProvider entity - Map metadata:coin:azure_domain_hint in PushMetadataAssembler - Append whr= in Bindings::send() HTTP-Redirect branch when IdP has the coin - Integration test: azure_domain_hint coin round-trips through PushMetadataAssembler - Legacy test: Bindings appends / omits whr= based on coin presence - Behat scenario: AzureDomainHint.feature covers the full SSO flow * fix: use correct URL separator when appending whr= to redirect URL * fix: check whr= absence at IdP redirect URL in negative Behat scenario Added IDP "<name>" prefers HTTP Redirect binding step and used it in the negative scenario so the URL assertion fires at the actual IdP redirect URL rather than at an intermediate EngineBlock page. * fix: add string type hint to setAzureDomainHintForIdp parameter * fix: add missing string type hint and improve docblocks and test assertions * chore: remove useless comments
1 parent 0da7533 commit 4e1fbba

11 files changed

Lines changed: 240 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ More information about our release strategy can be found in
1111
the [Development Guidelines](https://github.com/OpenConext/OpenConext-engineblock/wiki/Development-Guidelines#release-notes) on
1212
the EngineBlock wiki.
1313

14+
## UNRELEASED
15+
Features:
16+
* Added `coin:azure_domain_hint` configuration option for IdPs. When set, EngineBlock appends a `whr=<domain>` query parameter to the HTTP-Redirect AuthnRequest sent to the IdP, allowing Microsoft Azure / EntraID to skip the account picker (#1864).
17+
1418
## UNRELEASED 7.2.0
1519
Upgrade to Symfony 7.4
1620
Upgrade to `doctrine/dbal` 4

library/EngineBlock/Corto/Module/Bindings.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,15 @@ public function send(
710710
}
711711

712712
$url = $sspBinding->getRedirectURL($sspMessage);
713+
714+
if ($remoteEntity instanceof IdentityProvider) {
715+
$domainHint = $remoteEntity->getCoins()->azureDomainHint();
716+
if (!empty($domainHint)) {
717+
$separator = str_contains($url, '?') ? '&' : '?';
718+
$url .= $separator . http_build_query(['whr' => $domainHint]);
719+
}
720+
}
721+
713722
$this->_server->redirect($url, $message);
714723
}
715724
else {

src/OpenConext/EngineBlock/Metadata/Coins.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ public static function createForIdentityProvider(
7777
$signatureMethod,
7878
$mfaEntities,
7979
$defaultRAC,
80-
$policyEnforcementDecisionRequired
80+
bool $policyEnforcementDecisionRequired,
81+
?string $azureDomainHint = null
8182
) {
8283
return new self([
8384
'guestQualifier' => $guestQualifier,
@@ -90,6 +91,7 @@ public static function createForIdentityProvider(
9091
'mfaEntities' => $mfaEntities,
9192
'defaultRAC' => $defaultRAC,
9293
'policyEnforcementDecisionRequired' => $policyEnforcementDecisionRequired,
94+
'azureDomainHint' => $azureDomainHint,
9395
]);
9496
}
9597

@@ -250,6 +252,11 @@ public function mfaEntities(): MfaEntityCollection
250252
return $this->getValue('mfaEntities', MfaEntityCollection::fromCoin([]));
251253
}
252254

255+
public function azureDomainHint(): ?string
256+
{
257+
return $this->getValue('azureDomainHint');
258+
}
259+
253260
private function getValue($key, $default = null)
254261
{
255262
if (!array_key_exists($key, $this->values)) {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ private function assembleIdp(stdClass $connection)
269269
'policyEnforcementDecisionRequired'
270270
);
271271

272+
$properties += $this->setPathFromObjectString(
273+
[$connection, 'metadata:coin:azure_domain_hint'],
274+
'azureDomainHint'
275+
);
276+
272277
return Utils::instantiate(
273278
IdentityProvider::class,
274279
$properties

src/OpenConext/EngineBlock/Metadata/Entity/IdentityProvider.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class IdentityProvider extends AbstractRole
109109
/**
110110
* WARNING: Please don't use this entity directly but use the dedicated factory instead.
111111
* @see \OpenConext\EngineBlock\Metadata\Factory\Factory\IdentityProviderFactory
112+
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
112113
*/
113114
public function __construct(
114115
$entityId,
@@ -154,7 +155,8 @@ public function __construct(
154155
?MfaEntityCollection $mfaEntities = null,
155156
array $discoveries = [],
156157
?string $defaultRAC = null,
157-
bool $policyEnforcementDecisionRequired = false
158+
bool $policyEnforcementDecisionRequired = false,
159+
?string $azureDomainHint = null
158160
) {
159161
if (is_null($mdui)) {
160162
$mdui = Mdui::emptyMdui();
@@ -203,7 +205,8 @@ public function __construct(
203205
$signatureMethod,
204206
$mfaEntities,
205207
$defaultRAC,
206-
$policyEnforcementDecisionRequired
208+
$policyEnforcementDecisionRequired,
209+
$azureDomainHint
207210
);
208211

209212
$this->assertAllDiscoveries($discoveries);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Feature: Azure / EntraID domain hint
2+
In order to skip the Microsoft Azure account picker for users whose realm is known
3+
As an IdP operator
4+
I want to configure coin:azure_domain_hint on an IdP so that EngineBlock appends whr=<domain>
5+
to the HTTP-Redirect AuthnRequest URL it sends to that IdP
6+
7+
Background:
8+
Given an EngineBlock instance on "dev.openconext.local"
9+
And no registered SPs
10+
And no registered Idps
11+
And an Identity Provider named "Azure IdP"
12+
And a Service Provider named "Dummy SP"
13+
14+
Scenario: EngineBlock appends whr query parameter when coin:azure_domain_hint is configured
15+
Given IDP "Azure IdP" has Azure domain hint "hartingcollege.nl"
16+
When I log in at "Dummy SP"
17+
Then the full url should match "whr=hartingcollege\.nl"
18+
And I pass through the IdP
19+
And I give my consent
20+
And I pass through EngineBlock
21+
Then the url should match "functional-testing/Dummy%20SP/acs"
22+
23+
Scenario: EngineBlock does not append whr query parameter when coin:azure_domain_hint is not configured
24+
Given IDP "Azure IdP" prefers HTTP Redirect binding
25+
When I log in at "Dummy SP"
26+
Then the url should not match "whr="
27+
And I pass through the IdP
28+
And I give my consent
29+
And I pass through EngineBlock
30+
Then the url should match "functional-testing/Dummy%20SP/acs"

src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MinkContext.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
/**
3232
* Mink-enabled context.
33+
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
3334
*/
3435
class MinkContext extends BaseMinkContext
3536
{
@@ -216,6 +217,34 @@ public function theResponseShouldNotMatchXpath($xpath)
216217
}
217218
}
218219

220+
/**
221+
* @Then /^the full url should match (?P<pattern>"(?:[^"]|\\")*")$/
222+
*/
223+
public function assertFullUrlRegExp($pattern)
224+
{
225+
$url = $this->getSession()->getCurrentUrl();
226+
if (!preg_match($this->fixStepArgument($pattern), $url)) {
227+
throw new ExpectationException(
228+
sprintf('Current page "%s" does not match the regex "%s".', $url, $pattern),
229+
$this->getSession()
230+
);
231+
}
232+
}
233+
234+
/**
235+
* @Then /^the (?i)url(?-i) should not match (?P<pattern>"(?:[^"]|\\")*")$/
236+
*/
237+
public function assertUrlNotRegExp($pattern)
238+
{
239+
$url = $this->getSession()->getCurrentUrl();
240+
if (preg_match($this->fixStepArgument($pattern), $url)) {
241+
throw new ExpectationException(
242+
sprintf('URL "%s" matched pattern "%s", but it should not.', $url, $pattern),
243+
$this->getSession()
244+
);
245+
}
246+
}
247+
219248
/**
220249
* @Given /^I should see URL "([^"]*)"$/
221250
*/

src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/MockIdpContext.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,34 @@ public function idpRequiresAPolicyEnforcementDecision($idpName)
129129
->save();
130130
}
131131

132+
/**
133+
* @Given /^IDP "([^"]*)" has Azure domain hint "([^"]*)"$/
134+
* @param string $idpName
135+
* @param string $domainHint
136+
*/
137+
public function idpHasAzureDomainHint($idpName, $domainHint)
138+
{
139+
$idp = $this->mockIdpRegistry->get($idpName);
140+
141+
$this->serviceRegistryFixture
142+
->setAzureDomainHintForIdp($idp->entityId(), $domainHint)
143+
->preferHttpRedirectBindingForIdp($idp->entityId())
144+
->save();
145+
}
146+
147+
/**
148+
* @Given /^IDP "([^"]*)" prefers HTTP Redirect binding$/
149+
* @param string $idpName
150+
*/
151+
public function idpPrefersHttpRedirectBinding($idpName)
152+
{
153+
$idp = $this->mockIdpRegistry->get($idpName);
154+
155+
$this->serviceRegistryFixture
156+
->preferHttpRedirectBindingForIdp($idp->entityId())
157+
->save();
158+
}
159+
132160
/**
133161
* @Given /^an Identity Provider named "([^"]*)" with logo "([^"]*)"$/
134162
*/

src/OpenConext/EngineBlockFunctionalTestingBundle/Fixtures/ServiceRegistryFixture.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ class ServiceRegistryFixture
6262
*/
6363
private $entityManager;
6464

65-
6665
public function __construct(DoctrineMetadataRepository $repository, EntityManager $em)
6766
{
6867
$this->repository = $repository;
@@ -342,6 +341,42 @@ public function requiresPolicyEnforcementDecisionForIdp($entityId)
342341
return $this;
343342
}
344343

344+
public function setAzureDomainHintForIdp(string $entityId, string $domainHint): self
345+
{
346+
$idp = $this->getIdentityProvider($entityId);
347+
assert($idp instanceof IdentityProvider);
348+
349+
$this->setCoin($idp, 'azureDomainHint', $domainHint);
350+
351+
return $this;
352+
}
353+
354+
public function preferHttpRedirectBindingForIdp(string $entityId): self
355+
{
356+
$idp = $this->getIdentityProvider($entityId);
357+
assert($idp instanceof IdentityProvider);
358+
359+
$redirectServices = array_values(array_filter(
360+
$idp->singleSignOnServices,
361+
static fn(Service $s) => $s->binding === Constants::BINDING_HTTP_REDIRECT
362+
));
363+
364+
assert(
365+
count($redirectServices) > 0,
366+
'IdP has no HTTP-Redirect SSO binding; domain hint test would be meaningless'
367+
);
368+
369+
$idp->singleSignOnServices = $redirectServices;
370+
371+
// Force Doctrine to recognise the property change under DEFERRED_EXPLICIT change tracking.
372+
$this->entityManager->getUnitOfWork()->recomputeSingleEntityChangeSet(
373+
$this->entityManager->getClassMetadata(IdentityProvider::class),
374+
$idp
375+
);
376+
377+
return $this;
378+
}
379+
345380
public function requireAttributeAggregationForSp($entityId)
346381
{
347382
$arp = $this->getServiceProvider($entityId)->attributeReleasePolicy;

tests/integration/OpenConext/EngineBlock/Metadata/Entity/Assembler/PushMetadataAssemblerTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ public static function validCoins()
396396
['schachomeorganization', 'saml20-idp', 'schacHomeOrganization', 'string'],
397397
['policy_enforcement_decision_required', 'saml20-idp', 'policyEnforcementDecisionRequired', 'bool'],
398398
['hidden', 'saml20-idp', 'hidden', 'bool'],
399+
['azure_domain_hint', 'saml20-idp', 'azureDomainHint', 'string'],
399400

400401
// Abstract
401402
['disable_scoping', 'saml20-idp', 'disableScoping', 'bool'],

0 commit comments

Comments
 (0)