Skip to content

Commit d22585d

Browse files
authored
Enable defining additional private / public key pair for key rollover scenario (#283)
* Add key rollover options * Add coverage * Update docs
1 parent 444968f commit d22585d

File tree

6 files changed

+246
-56
lines changed

6 files changed

+246
-56
lines changed

README.md

+24-14
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ Currently supported flows are:
1717

1818
![Main screen capture](docs/oidc.png)
1919

20+
### Note on OpenID Federation (OIDF) support
21+
22+
OpenID Federation support is in "draft" phase, as is the
23+
[specification](https://openid.net/specs/openid-federation-1_0) itself. This means that you can expect braking changes
24+
in future releases related to OIDF capabilities. You can enable / disable OIDF support at any time in module
25+
configuration.
26+
27+
Currently, the following OIDF features are supported:
28+
* automatic client registration using a Request Object (passing it by value)
29+
* endpoint for issuing configuration entity statement (statement about itself)
30+
* fetch endpoint for issuing statements about subordinates (registered clients)
31+
32+
OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid).
33+
2034
## Version compatibility
2135

2236
Minor versions of SimpleSAMLphp noted below means that the module has been tested with that version of SimpleSAMLphp
@@ -150,6 +164,16 @@ Once you deploy the module, in the SimpleSAMLphp administration area go to `OIDC
150164
Protocol / Federation Settings page to see the available discovery URLs. These URLs can then be used to set up a
151165
`.well-known` URLs (see below).
152166

167+
### Key rollover
168+
169+
The module supports defining additional (new) private / public key pair to be published on relevant JWKS endpoint
170+
or contained in relevant JWKS property. In this way, you can "announce" new public key which can then be fetched
171+
by RPs in order to prepare for the switch of the keys (until the switch of keys, all artifacts continue to be
172+
signed with the "old" private key).
173+
174+
In this way, after RPs fetch new JWKS (JWKS with "old" and "new" key), you can do the switch of keys when you find
175+
appropriate.
176+
153177
### Note when using Apache web server
154178

155179
If you are using Apache web server, you might encounter situations in which Apache strips of Authorization header
@@ -168,20 +192,6 @@ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
168192
```
169193
Choose the one which works for you. If you don't set it, you'll get a warnings about this situation in your logs.
170194

171-
### Note on OpenID Federation (OIDF) support
172-
173-
OpenID Federation support is in "draft" phase, as is the
174-
[specification](https://openid.net/specs/openid-federation-1_0) itself. This means that you can expect braking changes
175-
in future releases related to OIDF capabilities. You can enable / disable OIDF support at any time in module
176-
configuration.
177-
178-
Currently, the following OIDF features are supported:
179-
* endpoint for issuing configuration entity statement (statement about itself)
180-
* fetch endpoint for issuing statements about subordinates (registered clients)
181-
* automatic client registration using a Request Object
182-
183-
OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid).
184-
185195
## Additional considerations
186196
### Private scopes
187197

UPGRADE.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
client and user data. The cache layer stands in front of the database store, so it can improve performance, especially
1313
in cases of sudden surge of users trying to authenticate. Implementation is based on Symfony Cache component, so any
1414
compatible Symfony cache adapter can be used. Check the module config file for more information on how to set the
15-
protocol cache.
15+
protocol cache.
16+
- Key rollover support - you can now define additional (new) private / public key pair which will be published on
17+
relevant JWKS endpoint or contained in JWKS property. In this way, you can "announce" new public key which can then
18+
be fetched by RPs, and do the switch between "old" and "new" key pair when you find appropriate.
1619
- OpenID capabilities
1720
- New federation endpoints:
1821
- endpoint for issuing configuration entity statement (statement about itself)

config/module_oidc.php.dist

+27-6
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,30 @@ $config = [
2727
* is a case-sensitive URL using the https scheme that contains scheme, host, and optionally, port number and
2828
* path components and no query or fragment components."
2929
*/
30-
//ModuleConfig::OPTION_ISSUER => 'https://op.example.org',
30+
// ModuleConfig::OPTION_ISSUER => 'https://op.example.org',
3131

3232
/**
3333
* PKI (public / private key) settings related to OIDC protocol. These keys will be used, for example, to
3434
* sign ID Token JWT.
3535
*/
3636
// (optional) The private key passphrase.
37-
//ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret',
37+
// ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret',
3838
// The certificate and private key filenames, with given defaults.
3939
ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME,
4040
ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME,
4141

42+
/**
43+
* (optional) Key rollover settings related to OIDC protocol. If set, this new private / public key pair will only
44+
* be published on JWKS endpoint as available, so Relying Parties can pick them up for future use. The signing
45+
* of artifacts will still be done using the 'current' private key (settings above). After some time, when all
46+
* RPs have fetched all public keys from JWKS endpoint, simply set these new keys as active values for above
47+
* PKI options.
48+
*/
49+
// // (optional) The (new) private key passphrase.
50+
// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret',
51+
// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key',
52+
// ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt',
53+
4254
/**
4355
* Token related options.
4456
*/
@@ -51,8 +63,8 @@ $config = [
5163
// Token signer, with given default.
5264
// See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer
5365
ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class,
54-
//ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class,
55-
//ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class,
66+
// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class,
67+
// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class,
5668

5769
/**
5870
* Authentication related options.
@@ -347,7 +359,7 @@ $config = [
347359
// Federation authority hints. An array of strings representing the Entity Identifiers of Intermediate Entities
348360
// (or Trust Anchors). Required if this entity has a Superior entity above it.
349361
ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [
350-
//'https://intermediate.example.org/',
362+
// 'https://intermediate.example.org/',
351363
],
352364

353365
// (optional) Federation Trust Mark tokens. An array of tokens (signed JWTs), each representing a Trust Mark
@@ -411,13 +423,22 @@ $config = [
411423
* entity statements. Note that these keys SHOULD NOT be the same as the ones used in OIDC protocol itself.
412424
*/
413425
// The federation private key passphrase (optional).
414-
//ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret',
426+
// ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret',
415427
// The federation certificate and private key filenames, with given defaults.
416428
ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME =>
417429
ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME,
418430
ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME =>
419431
ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME,
420432

433+
/**
434+
* (optional) Key rollover settings related to OpenID Federation. Check the OIDC protocol key rollover description
435+
* on how this works.
436+
*/
437+
// The federation (new) private key passphrase (optional).
438+
// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret',
439+
// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module_federation.key',
440+
// ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt',
441+
421442
// Federation token signer, with given default.
422443
ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class,
423444

src/ModuleConfig.php

+43-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ class ModuleConfig
8484
final public const OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS =
8585
'federation_participation_limit_by_trust_marks';
8686

87+
final public const OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE = 'new_private_key_passphrase';
88+
final public const OPTION_PKI_NEW_PRIVATE_KEY_FILENAME = 'new_privatekey';
89+
final public const OPTION_PKI_NEW_CERTIFICATE_FILENAME = 'new_certificate';
90+
91+
final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE = 'federation_new_private_key_passphrase';
92+
final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME = 'federation_new_private_key_filename';
93+
final public const OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME = 'federation_new_certificate_filename';
94+
8795
protected static array $standardScopes = [
8896
ScopesEnum::OpenId->value => [
8997
self::KEY_DESCRIPTION => 'openid',
@@ -365,6 +373,22 @@ public function getProtocolCertPath(): string
365373
return $this->sspBridge->utils()->config()->getCertPath($certName);
366374
}
367375

376+
/**
377+
* Get the path to the new public certificate to be used in OIDC protocol.
378+
* @return ?string Null if not set, or file system path
379+
* @throws \Exception
380+
*/
381+
public function getProtocolNewCertPath(): ?string
382+
{
383+
$certName = $this->config()->getOptionalString(self::OPTION_PKI_NEW_CERTIFICATE_FILENAME, null);
384+
385+
if (is_string($certName)) {
386+
return $this->sspBridge->utils()->config()->getCertPath($certName);
387+
}
388+
389+
return null;
390+
}
391+
368392
/**
369393
* Get supported Authentication Context Class References (ACRs).
370394
*
@@ -522,7 +546,6 @@ public function getFederationPrivateKeyPassPhrase(): ?string
522546

523547
/**
524548
* Return the path to the federation public certificate
525-
* @return string The file system path or null if not set.
526549
* @throws \Exception
527550
*/
528551
public function getFederationCertPath(): string
@@ -535,6 +558,25 @@ public function getFederationCertPath(): string
535558
return $this->sspBridge->utils()->config()->getCertPath($certName);
536559
}
537560

561+
/**
562+
* Return the path to the new federation public certificate
563+
* @return ?string The file system path or null if not set.
564+
* @throws \Exception
565+
*/
566+
public function getFederationNewCertPath(): ?string
567+
{
568+
$certName = $this->config()->getOptionalString(
569+
self::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME,
570+
null,
571+
);
572+
573+
if (is_string($certName)) {
574+
return $this->sspBridge->utils()->config()->getCertPath($certName);
575+
}
576+
577+
return null;
578+
}
579+
538580
/**
539581
* @throws \Exception
540582
*/

src/Services/JsonWebKeySetService.php

+75-29
Original file line numberDiff line numberDiff line change
@@ -27,42 +27,20 @@
2727
class JsonWebKeySetService
2828
{
2929
/** @var JWKSet JWKS for OIDC protocol. */
30-
private readonly JWKSet $protocolJwkSet;
30+
protected JWKSet $protocolJwkSet;
3131
/** @var JWKSet|null JWKS for OpenID Federation. */
32-
private ?JWKSet $federationJwkSet = null;
32+
protected ?JWKSet $federationJwkSet = null;
3333

3434
/**
3535
* @throws \SimpleSAML\Error\Exception
3636
* @throws \Exception
3737
*/
38-
public function __construct(ModuleConfig $moduleConfig)
39-
{
40-
$publicKeyPath = $moduleConfig->getProtocolCertPath();
41-
if (!file_exists($publicKeyPath)) {
42-
throw new Error\Exception("OIDC protocol public key file does not exists: $publicKeyPath.");
43-
}
38+
public function __construct(
39+
protected readonly ModuleConfig $moduleConfig,
40+
) {
41+
$this->prepareProtocolJwkSet();
4442

45-
$jwk = JWKFactory::createFromKeyFile($publicKeyPath, null, [
46-
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($publicKeyPath),
47-
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
48-
ClaimsEnum::Alg->value => $moduleConfig->getProtocolSigner()->algorithmId(),
49-
]);
50-
51-
$this->protocolJwkSet = new JWKSet([$jwk]);
52-
53-
if (
54-
($federationPublicKeyPath = $moduleConfig->getFederationCertPath()) &&
55-
file_exists($federationPublicKeyPath) &&
56-
($federationSigner = $moduleConfig->getFederationSigner())
57-
) {
58-
$federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [
59-
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath),
60-
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
61-
ClaimsEnum::Alg->value => $federationSigner->algorithmId(),
62-
]);
63-
64-
$this->federationJwkSet = new JWKSet([$federationJwk]);
65-
}
43+
$this->prepareFederationJwkSet();
6644
}
6745

6846
/**
@@ -84,4 +62,72 @@ public function federationKeys(): array
8462

8563
return $this->federationJwkSet->all();
8664
}
65+
66+
/**
67+
* @throws \ReflectionException
68+
* @throws \SimpleSAML\Error\Exception
69+
*/
70+
protected function prepareProtocolJwkSet(): void
71+
{
72+
$protocolPublicKeyPath = $this->moduleConfig->getProtocolCertPath();
73+
74+
if (!file_exists($protocolPublicKeyPath)) {
75+
throw new Error\Exception("OIDC protocol public key file does not exists: $protocolPublicKeyPath.");
76+
}
77+
78+
$jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [
79+
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath),
80+
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
81+
ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(),
82+
]);
83+
84+
$keys = [$jwk];
85+
86+
if (
87+
($protocolNewPublicKeyPath = $this->moduleConfig->getProtocolNewCertPath()) &&
88+
file_exists($protocolNewPublicKeyPath)
89+
) {
90+
$newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [
91+
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath),
92+
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
93+
ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(),
94+
]);
95+
96+
$keys[] = $newJwk;
97+
}
98+
99+
$this->protocolJwkSet = new JWKSet($keys);
100+
}
101+
102+
protected function prepareFederationJwkSet(): void
103+
{
104+
$federationPublicKeyPath = $this->moduleConfig->getFederationCertPath();
105+
106+
if (!file_exists($federationPublicKeyPath)) {
107+
return;
108+
}
109+
110+
$federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [
111+
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath),
112+
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
113+
ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(),
114+
]);
115+
116+
$keys = [$federationJwk];
117+
118+
if (
119+
($federationNewPublicKeyPath = $this->moduleConfig->getFederationNewCertPath()) &&
120+
file_exists($federationNewPublicKeyPath)
121+
) {
122+
$federationNewJwk = JWKFactory::createFromKeyFile($federationNewPublicKeyPath, null, [
123+
ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationNewPublicKeyPath),
124+
ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value,
125+
ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(),
126+
]);
127+
128+
$keys[] = $federationNewJwk;
129+
}
130+
131+
$this->federationJwkSet = new JWKSet($keys);
132+
}
87133
}

0 commit comments

Comments
 (0)