Skip to content
Draft
15 changes: 15 additions & 0 deletions config/authsources.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ $config = [
*/
'proxymode.passAuthnContextClassRef' => false,

/*
* Map requested AuthnContextClassRef to contexts supported by the IdP.
* This allows replacing requested context classes (e.g. from an SP behind a proxy)
* with context classes that the upstream IdP actually supports, before the request
* is sent. If a requested context matches a key in this array, it will be replaced
* by the value. You can map to a single string, an array of strings, or an empty string/array
* to drop the requested context (fallback to standard login).
*/
/*
'AuthnContextClassRefMapping' => [
'https://refeds.org/profile/mfa/phr' => 'https://refeds.org/profile/mfa',
'urn:oasis:names:tc:SAML:2.0:ac:classes:Password' => '',
],
*/

/*
* The attributes parameter must contain an array of desired attributes by the SP.
* The attributes can be expressed as an array of names or as an associative array
Expand Down
11 changes: 11 additions & 0 deletions docs/simplesamlphp-reference-idp-remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ $metadata['entity-id-2'] = [
`AuthnContextClassRef`
: The AuthnContextClassRef that will be sent in the login request.

: Note that this option also exists in the SP configuration. This
entry in the IdP-remote metadata overrides the option in the
[SP configuration](./saml:sp).

`AuthnContextClassRefMapping`
: A mapping array to translate requested `AuthnContextClassRef` values to ones supported by the IdP.
If a requested context (e.g., from an SP behind a proxy) matches a key in this array, it is replaced
by the value before the request is sent. The value can be a single string, an array of strings,
or an empty string/array to drop the context entirely (falling back to standard login).
For a detailed flow diagram of how this mapping works, see the [SP configuration reference](./saml:sp).

: Note that this option also exists in the SP configuration. This
entry in the IdP-remote metadata overrides the option in the
[SP configuration](./saml:sp).
Expand Down
26 changes: 26 additions & 0 deletions modules/saml/docs/sp.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,32 @@
One example of usage could be if the IdP supports both username/password authentication as well as software-PKI.
Set this to a string for one class identifier or an array of requested class identifiers.

`AuthnContextClassRefMapping`
: A mapping array to translate requested `AuthnContextClassRef` values to ones supported by the IdP.
If a requested context (e.g., from an SP behind a proxy) matches a key in this array, it is replaced
by the value before the request is sent. The value can be a single string, an array of strings,
or an empty string/array to drop the context entirely (falling back to standard login).

```mermaid

Check failure on line 156 in modules/saml/docs/sp.md

View workflow job for this annotation

GitHub Actions / Quality checks

Code block style [Expected: indented; Actual: fenced]
sequenceDiagram
participant App as Original Application
participant Proxy as SimpleSAMLphp Proxy
participant IdP as Upstream IdP

App->>Proxy: 1. Login Request<br/>(e.g., asks for Phishing-Resistant MFA)

rect rgb(240, 248, 255)
Note over Proxy: 2. Pre-flight Mapping Phase
Proxy->>Proxy: Check requested AuthnContext
Proxy->>Proxy: Apply AuthnContextClassRefMapping<br/>(Translates to standard MFA, or drops<br/>it to fallback to standard login)
end

Proxy->>IdP: 3. Send ONE valid SAML AuthnRequest<br/>(using the mapped/supported context)

IdP-->>Proxy: 4. Successful Authentication Response
Proxy-->>App: 5. Successful Login
```

`AuthnContextComparison`
: The Comparison attribute of the AuthnContext that will be sent in the login request.
This parameter won't be used unless `saml:AuthnContextClassRef` is set and contains one or more values.
Expand Down
37 changes: 35 additions & 2 deletions modules/saml/src/Auth/Source/SP.php
Original file line number Diff line number Diff line change
Expand Up @@ -503,10 +503,43 @@ private function startSSO2(Configuration $idpMetadata, array $state): void
$arrayUtils = new Utils\Arrays();

$accr = null;
if ($idpMetadata->getOptionalString('AuthnContextClassRef', null) !== null) {
$accr = $arrayUtils->arrayize($idpMetadata->getString('AuthnContextClassRef'));
if ($idpMetadata->hasValue('AuthnContextClassRef')) {
$accr = $idpMetadata->getOptionalArrayizeString('AuthnContextClassRef', null);
} elseif (isset($state['saml:AuthnContextClassRef'])) {
$accr = $arrayUtils->arrayize($state['saml:AuthnContextClassRef']);
} elseif ($this->metadata->hasValue('AuthnContextClassRef')) {
$accr = $this->metadata->getOptionalArrayizeString('AuthnContextClassRef', null);
}

// Apply AuthnContextClassRefMapping if available
if ($accr !== null) {
$mapping = $idpMetadata->getOptionalArray('AuthnContextClassRefMapping', null);
if ($mapping === null) {
$mapping = $this->metadata->getOptionalArray('AuthnContextClassRefMapping', null);
}

if ($mapping !== null) {
$mappedAccr = [];
foreach ($accr as $ref) {
if (array_key_exists($ref, $mapping)) {
$mappedValue = $mapping[$ref];
if (is_array($mappedValue)) {
$mappedAccr = array_merge($mappedAccr, $mappedValue);
} elseif (is_string($mappedValue) && $mappedValue !== '') {
$mappedAccr[] = $mappedValue;
}
} else {
$mappedAccr[] = $ref;
}
}

// An empty array implies a fallback to no context (don't set AuthnContextClassRef)
if (empty($mappedAccr) || (count($mappedAccr) === 1 && $mappedAccr[0] === '')) {
$accr = null;
} else {
$accr = array_values(array_unique($mappedAccr));
}
}
}

if ($accr !== null) {
Expand Down
78 changes: 78 additions & 0 deletions tests/modules/saml/src/Auth/Source/SPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,84 @@ public function testAuthnContextClassRef(): void
}


/**
* Test mapping of AuthnContextClassRef
*/
public function testAuthnContextClassRefMapping(): void
{
$info = ['AuthId' => 'default-sp'];
$config = ['entityID' => 'urn:x-simplesamlphp:example-sp'];
$as = new SpTester($info, $config);

$idpConfigArray = $this->idpConfigArray;
$idpConfigArray['AuthnContextClassRefMapping'] = [
'https://refeds.org/profile/mfa/phr' => 'https://refeds.org/profile/mfa',
'urn:oasis:names:tc:SAML:2.0:ac:classes:Password' => '',
'https://refeds.org/profile/sfa' => [
'urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
],
];
$idpMetadata = new \SimpleSAML\Configuration($idpConfigArray, 'test-idp');

// Test single string mapping
$state = [
'saml:AuthnContextClassRef' => 'https://refeds.org/profile/mfa/phr',
];

try {
$as->startSSO2Test($idpMetadata, $state);
$this->fail('Expected ExitTestException');
} catch (\SimpleSAML\Test\Utils\ExitTestException $e) {
$r = $e->getTestResult();
$ar = $r['ar'];
}

$a = $ar->getRequestedAuthnContext();
$this->assertEquals(
'https://refeds.org/profile/mfa',
$a['AuthnContextClassRef'][0],
);

// Test fallback to empty context
$state2 = [
'saml:AuthnContextClassRef' => 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
];

try {
$as->startSSO2Test($idpMetadata, $state2);
$this->fail('Expected ExitTestException');
} catch (\SimpleSAML\Test\Utils\ExitTestException $e) {
$r = $e->getTestResult();
$ar2 = $r['ar'];
}

$this->assertNull($ar2->getRequestedAuthnContext());

// Test mapping to array
$state3 = [
'saml:AuthnContextClassRef' => 'https://refeds.org/profile/sfa',
];

try {
$as->startSSO2Test($idpMetadata, $state3);
$this->fail('Expected ExitTestException');
} catch (\SimpleSAML\Test\Utils\ExitTestException $e) {
$r = $e->getTestResult();
$ar3 = $r['ar'];
}

$a3 = $ar3->getRequestedAuthnContext();
$this->assertEquals(
[
'urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
],
$a3['AuthnContextClassRef'],
);
}


/**
* Test setting ForcedAuthn
*/
Expand Down
1 change: 1 addition & 0 deletions tests/src/SimpleSAML/XHTML/TemplateTranslationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ function () {

$files = Finder::create()
->name('*.twig')
->exclude('vendor')
->in(
[
$root . '/templates',
Expand Down
Loading