Skip to content
Open
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
124 changes: 108 additions & 16 deletions src/Saml2/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use LightSaml\Helper;
use LightSaml\Model\Assertion\Assertion;
use LightSaml\Model\Assertion\AttributeStatement;
use LightSaml\Model\Assertion\EncryptedAssertionReader;
use LightSaml\Model\Assertion\Issuer;
use LightSaml\Model\Assertion\NameID;
use LightSaml\Model\Context\DeserializationContext;
Expand Down Expand Up @@ -158,6 +159,9 @@ public static function additionalConfigKeys(): array
'sp_certificate',
'sp_private_key',
'sp_private_key_passphrase',
'sp_previous_certificate',
'sp_previous_private_key',
'sp_previous_private_key_passphrase',
'sp_tech_contact_surname',
'sp_tech_contact_givenname',
'sp_tech_contact_email',
Expand Down Expand Up @@ -407,11 +411,14 @@ public function getServiceProviderEntityDescriptor(): EntityDescriptor
->setEntityID($this->getConfig('sp_entityid', URL::to('auth/saml2')))
->addItem($spSsoDescriptor);

if ($credential = $this->credential()) {
if ($credential = $this->signingCredential()) {
$entityDescriptor->setSignature($this->signature($credential));
$spSsoDescriptor->setAuthnRequestsSigned(true)
->addKeyDescriptor(new KeyDescriptor(KeyDescriptor::USE_SIGNING, $credential->getCertificate()))
->addKeyDescriptor(new KeyDescriptor(KeyDescriptor::USE_ENCRYPTION, $credential->getCertificate()));
$spSsoDescriptor->setAuthnRequestsSigned(true);

foreach ($this->metadataCertificates() as $certificate) {
$spSsoDescriptor->addKeyDescriptor(new KeyDescriptor(KeyDescriptor::USE_SIGNING, $certificate));
$spSsoDescriptor->addKeyDescriptor(new KeyDescriptor(KeyDescriptor::USE_ENCRYPTION, $certificate));
}
}

if ($this->getConfig('sp_org_name')) {
Expand Down Expand Up @@ -657,20 +664,25 @@ protected function receive(): void

protected function decryptAssertions(): void
{
$credential = $this->credential();
if ($credential === null) {
return;
}

/** @var \LightSaml\Model\Assertion\EncryptedAssertionReader $reader */
/** @var EncryptedAssertionReader|null $reader */
$reader = $this->messageContext->asResponse()->getFirstEncryptedAssertion();

if ($reader === null) {
return;
}

$assertion = $reader->decryptAssertion($credential->getPrivateKey(), new DeserializationContext);
$this->messageContext->asResponse()->addAssertion($assertion);
foreach ($this->decryptionCredentials() as $credential) {
try {
$assertion = $reader->decryptAssertion($credential->getPrivateKey(), new DeserializationContext);
$this->messageContext->asResponse()->addAssertion($assertion);

return;
} catch (LightSamlSecurityException) {
continue;
}
}

throw new LightSamlSecurityException('The encrypted assertion could not be decrypted');
}

public function getServiceProviderMetadata(): Response
Expand All @@ -684,6 +696,14 @@ public function getServiceProviderMetadata(): Response
->setContent($serializationContext->getDocument()->saveXML());
}

/**
* @return X509Certificate[]
*/
public function getServiceProviderCertificates(): array
{
return $this->metadataCertificates();
}

public function clearIdentityProviderMetadataCache()
{
Cache::forget($this->cacheKey('metadata'));
Expand All @@ -701,15 +721,87 @@ protected function signature(X509Credential $credential): SignatureWriter

protected function credential(): ?X509Credential
{
if (! $this->getConfig('sp_certificate') || ! $this->getConfig('sp_private_key')) {
return $this->signingCredential();
}

protected function signingCredential(): ?X509Credential
{
return $this->makeCredential(
$this->getConfig('sp_certificate'),
$this->getConfig('sp_private_key'),
$this->getConfig('sp_private_key_passphrase')
);
}

protected function previousCredential(): ?X509Credential
{
$this->validateOverlappingCredentialConfig();

return $this->makeCredential(
$this->getConfig('sp_previous_certificate'),
$this->getConfig('sp_previous_private_key'),
$this->getConfig('sp_previous_private_key_passphrase')
);
}

/**
* @return X509Credential[]
*/
protected function decryptionCredentials(): array
{
return array_values(array_filter([
$this->signingCredential(),
$this->previousCredential(),
]));
}

/**
* @return X509Certificate[]
*/
protected function metadataCertificates(): array
{
$this->validateOverlappingCredentialConfig();

$certificates = [];

foreach ([$this->signingCredential(), $this->previousCredential()] as $credential) {
if ($credential === null) {
continue;
}

$data = $credential->getCertificate()->getData();

if (! isset($certificates[$data])) {
$certificates[$data] = $credential->getCertificate();
}
}

return array_values($certificates);
}

protected function validateOverlappingCredentialConfig(): void
{
$hasPreviousCertificate = (bool) $this->getConfig('sp_previous_certificate');
$hasPreviousPrivateKey = (bool) $this->getConfig('sp_previous_private_key');

if ($hasPreviousCertificate xor $hasPreviousPrivateKey) {
throw new MissingConfigException(
'When using a previous service provider certificate during overlap rotation, both "sp_previous_certificate" and "sp_previous_private_key" must be set.'
);
}
}

protected function makeCredential(?string $certificate, ?string $privateKey, ?string $passphrase): ?X509Credential
{
if (! $certificate || ! $privateKey) {
return null;
}

return new X509Credential(
$this->makeCertificate($this->getConfig('sp_certificate')),
$this->makeCertificate($certificate),
KeyHelper::createPrivateKey(
$this->getConfig('sp_private_key'),
$this->getConfig('sp_private_key_passphrase'),
$privateKey,
$passphrase,
false,
XMLSecurityKey::RSA_SHA256
)
Expand Down
37 changes: 37 additions & 0 deletions src/Saml2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,43 @@ An example command to generate a certificate and private key with openssl:
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout sp_saml.pem -out sp_saml.crt
```

### Service provider certificate rotation

Identity providers that pin your service provider signing certificate require a coordinated rotation. This mirrors the package's existing support for multiple identity provider signing certificates in metadata: during an overlap period, publish both the active and previous certificates while signing outbound messages with the active key only.

Configure the active credential as usual, and optionally add the previous credential while customers update their identity provider:

```php
'saml2' => [
'sp_certificate' => file_get_contents('path/to/sp_saml.crt'),
'sp_private_key' => file_get_contents('path/to/sp_saml.pem'),

// Optional overlap credential during rotation
'sp_previous_certificate' => file_get_contents('path/to/sp_saml_previous.crt'),
'sp_previous_private_key' => file_get_contents('path/to/sp_saml_previous.pem'),
'sp_previous_private_key_passphrase' => null,
],
```

During overlap:

- Service provider metadata includes both certificates for signing and encryption.
- `AuthnRequest`, `LogoutRequest`, and `LogoutResponse` messages are signed with the active key only.
- Encrypted assertions are decrypted with the active key first, then the previous key.

Recommended workflow:

1. Deploy the new active certificate and private key.
2. Keep the replaced certificate and private key in the `sp_previous_*` config keys for 30–90 days.
3. Notify identity provider administrators to refresh metadata or update the pinned signing certificate.
4. Remove the `sp_previous_*` keys after the grace period.

Retrieve all currently published service provider certificates programmatically:

```php
Socialite::driver('saml2')->getServiceProviderCertificates();
```

### Validation

The provider validates the timestamps in the assertion including `NotBefore` and `NotOnOrAfter`.
Expand Down
Loading