diff --git a/README.md b/README.md index 2e71b576..81f947b8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,20 @@ Currently supported flows are: ![Main screen capture](docs/oidc.png) +### Note on OpenID Federation (OIDF) support + +OpenID Federation support is in "draft" phase, as is the +[specification](https://openid.net/specs/openid-federation-1_0) itself. This means that you can expect braking changes +in future releases related to OIDF capabilities. You can enable / disable OIDF support at any time in module +configuration. + +Currently, the following OIDF features are supported: +* automatic client registration using a Request Object (passing it by value) +* endpoint for issuing configuration entity statement (statement about itself) +* fetch endpoint for issuing statements about subordinates (registered clients) + +OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). + ## Version compatibility 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 Protocol / Federation Settings page to see the available discovery URLs. These URLs can then be used to set up a `.well-known` URLs (see below). +### Key rollover + +The module supports defining additional (new) private / public key pair to be published on relevant JWKS endpoint +or contained in relevant JWKS property. In this way, you can "announce" new public key which can then be fetched +by RPs in order to prepare for the switch of the keys (until the switch of keys, all artifacts continue to be +signed with the "old" private key). + +In this way, after RPs fetch new JWKS (JWKS with "old" and "new" key), you can do the switch of keys when you find +appropriate. + ### Note when using Apache web server 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 ``` Choose the one which works for you. If you don't set it, you'll get a warnings about this situation in your logs. -### Note on OpenID Federation (OIDF) support - -OpenID Federation support is in "draft" phase, as is the -[specification](https://openid.net/specs/openid-federation-1_0) itself. This means that you can expect braking changes -in future releases related to OIDF capabilities. You can enable / disable OIDF support at any time in module -configuration. - -Currently, the following OIDF features are supported: -* endpoint for issuing configuration entity statement (statement about itself) -* fetch endpoint for issuing statements about subordinates (registered clients) -* automatic client registration using a Request Object - -OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). - ## Additional considerations ### Private scopes diff --git a/UPGRADE.md b/UPGRADE.md index 3a71139b..bc43e419 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -12,7 +12,10 @@ client and user data. The cache layer stands in front of the database store, so it can improve performance, especially in cases of sudden surge of users trying to authenticate. Implementation is based on Symfony Cache component, so any compatible Symfony cache adapter can be used. Check the module config file for more information on how to set the - protocol cache. + protocol cache. +- Key rollover support - you can now define additional (new) private / public key pair which will be published on +relevant JWKS endpoint or contained in JWKS property. In this way, you can "announce" new public key which can then +be fetched by RPs, and do the switch between "old" and "new" key pair when you find appropriate. - OpenID capabilities - New federation endpoints: - endpoint for issuing configuration entity statement (statement about itself) diff --git a/config/module_oidc.php.dist b/config/module_oidc.php.dist index a48b6169..caea6492 100644 --- a/config/module_oidc.php.dist +++ b/config/module_oidc.php.dist @@ -27,18 +27,30 @@ $config = [ * is a case-sensitive URL using the https scheme that contains scheme, host, and optionally, port number and * path components and no query or fragment components." */ - //ModuleConfig::OPTION_ISSUER => 'https://op.example.org', +// ModuleConfig::OPTION_ISSUER => 'https://op.example.org', /** * PKI (public / private key) settings related to OIDC protocol. These keys will be used, for example, to * sign ID Token JWT. */ // (optional) The private key passphrase. - //ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret', +// ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE => 'secret', // The certificate and private key filenames, with given defaults. ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, + /** + * (optional) Key rollover settings related to OIDC protocol. If set, this new private / public key pair will only + * be published on JWKS endpoint as available, so Relying Parties can pick them up for future use. The signing + * of artifacts will still be done using the 'current' private key (settings above). After some time, when all + * RPs have fetched all public keys from JWKS endpoint, simply set these new keys as active values for above + * PKI options. + */ +// // (optional) The (new) private key passphrase. +// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', +// ModuleConfig::OPTION_PKI_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module.key', +// ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', + /** * Token related options. */ @@ -51,8 +63,8 @@ $config = [ // Token signer, with given default. // See Lcobucci\JWT\Signer algorithms in https://github.com/lcobucci/jwt/tree/master/src/Signer ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, - //ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, - //ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, +// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Hmac\Sha256::class, +// ModuleConfig::OPTION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Ecdsa\Sha256::class, /** * Authentication related options. @@ -347,7 +359,7 @@ $config = [ // Federation authority hints. An array of strings representing the Entity Identifiers of Intermediate Entities // (or Trust Anchors). Required if this entity has a Superior entity above it. ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ - //'https://intermediate.example.org/', +// 'https://intermediate.example.org/', ], // (optional) Federation Trust Mark tokens. An array of tokens (signed JWTs), each representing a Trust Mark @@ -411,13 +423,22 @@ $config = [ * entity statements. Note that these keys SHOULD NOT be the same as the ones used in OIDC protocol itself. */ // The federation private key passphrase (optional). - //ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret', +// ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'secret', // The federation certificate and private key filenames, with given defaults. ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, + /** + * (optional) Key rollover settings related to OpenID Federation. Check the OIDC protocol key rollover description + * on how this works. + */ + // The federation (new) private key passphrase (optional). +// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE => 'new-secret', +// ModuleConfig::OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME => 'new_oidc_module_federation.key', +// ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', + // Federation token signer, with given default. ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => \Lcobucci\JWT\Signer\Rsa\Sha256::class, diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index dcb2de51..730758a2 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -84,6 +84,14 @@ class ModuleConfig final public const OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS = 'federation_participation_limit_by_trust_marks'; + final public const OPTION_PKI_NEW_PRIVATE_KEY_PASSPHRASE = 'new_private_key_passphrase'; + final public const OPTION_PKI_NEW_PRIVATE_KEY_FILENAME = 'new_privatekey'; + final public const OPTION_PKI_NEW_CERTIFICATE_FILENAME = 'new_certificate'; + + final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_PASSPHRASE = 'federation_new_private_key_passphrase'; + final public const OPTION_PKI_FEDERATION_NEW_PRIVATE_KEY_FILENAME = 'federation_new_private_key_filename'; + final public const OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME = 'federation_new_certificate_filename'; + protected static array $standardScopes = [ ScopesEnum::OpenId->value => [ self::KEY_DESCRIPTION => 'openid', @@ -365,6 +373,22 @@ public function getProtocolCertPath(): string return $this->sspBridge->utils()->config()->getCertPath($certName); } + /** + * Get the path to the new public certificate to be used in OIDC protocol. + * @return ?string Null if not set, or file system path + * @throws \Exception + */ + public function getProtocolNewCertPath(): ?string + { + $certName = $this->config()->getOptionalString(self::OPTION_PKI_NEW_CERTIFICATE_FILENAME, null); + + if (is_string($certName)) { + return $this->sspBridge->utils()->config()->getCertPath($certName); + } + + return null; + } + /** * Get supported Authentication Context Class References (ACRs). * @@ -522,7 +546,6 @@ public function getFederationPrivateKeyPassPhrase(): ?string /** * Return the path to the federation public certificate - * @return string The file system path or null if not set. * @throws \Exception */ public function getFederationCertPath(): string @@ -535,6 +558,25 @@ public function getFederationCertPath(): string return $this->sspBridge->utils()->config()->getCertPath($certName); } + /** + * Return the path to the new federation public certificate + * @return ?string The file system path or null if not set. + * @throws \Exception + */ + public function getFederationNewCertPath(): ?string + { + $certName = $this->config()->getOptionalString( + self::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME, + null, + ); + + if (is_string($certName)) { + return $this->sspBridge->utils()->config()->getCertPath($certName); + } + + return null; + } + /** * @throws \Exception */ diff --git a/src/Services/JsonWebKeySetService.php b/src/Services/JsonWebKeySetService.php index f87b1428..f02f9e05 100644 --- a/src/Services/JsonWebKeySetService.php +++ b/src/Services/JsonWebKeySetService.php @@ -27,42 +27,20 @@ class JsonWebKeySetService { /** @var JWKSet JWKS for OIDC protocol. */ - private readonly JWKSet $protocolJwkSet; + protected JWKSet $protocolJwkSet; /** @var JWKSet|null JWKS for OpenID Federation. */ - private ?JWKSet $federationJwkSet = null; + protected ?JWKSet $federationJwkSet = null; /** * @throws \SimpleSAML\Error\Exception * @throws \Exception */ - public function __construct(ModuleConfig $moduleConfig) - { - $publicKeyPath = $moduleConfig->getProtocolCertPath(); - if (!file_exists($publicKeyPath)) { - throw new Error\Exception("OIDC protocol public key file does not exists: $publicKeyPath."); - } + public function __construct( + protected readonly ModuleConfig $moduleConfig, + ) { + $this->prepareProtocolJwkSet(); - $jwk = JWKFactory::createFromKeyFile($publicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($publicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $moduleConfig->getProtocolSigner()->algorithmId(), - ]); - - $this->protocolJwkSet = new JWKSet([$jwk]); - - if ( - ($federationPublicKeyPath = $moduleConfig->getFederationCertPath()) && - file_exists($federationPublicKeyPath) && - ($federationSigner = $moduleConfig->getFederationSigner()) - ) { - $federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [ - ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), - ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, - ClaimsEnum::Alg->value => $federationSigner->algorithmId(), - ]); - - $this->federationJwkSet = new JWKSet([$federationJwk]); - } + $this->prepareFederationJwkSet(); } /** @@ -84,4 +62,72 @@ public function federationKeys(): array return $this->federationJwkSet->all(); } + + /** + * @throws \ReflectionException + * @throws \SimpleSAML\Error\Exception + */ + protected function prepareProtocolJwkSet(): void + { + $protocolPublicKeyPath = $this->moduleConfig->getProtocolCertPath(); + + if (!file_exists($protocolPublicKeyPath)) { + throw new Error\Exception("OIDC protocol public key file does not exists: $protocolPublicKeyPath."); + } + + $jwk = JWKFactory::createFromKeyFile($protocolPublicKeyPath, null, [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolPublicKeyPath), + ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, + ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), + ]); + + $keys = [$jwk]; + + if ( + ($protocolNewPublicKeyPath = $this->moduleConfig->getProtocolNewCertPath()) && + file_exists($protocolNewPublicKeyPath) + ) { + $newJwk = JWKFactory::createFromKeyFile($protocolNewPublicKeyPath, null, [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($protocolNewPublicKeyPath), + ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, + ClaimsEnum::Alg->value => $this->moduleConfig->getProtocolSigner()->algorithmId(), + ]); + + $keys[] = $newJwk; + } + + $this->protocolJwkSet = new JWKSet($keys); + } + + protected function prepareFederationJwkSet(): void + { + $federationPublicKeyPath = $this->moduleConfig->getFederationCertPath(); + + if (!file_exists($federationPublicKeyPath)) { + return; + } + + $federationJwk = JWKFactory::createFromKeyFile($federationPublicKeyPath, null, [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationPublicKeyPath), + ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, + ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), + ]); + + $keys = [$federationJwk]; + + if ( + ($federationNewPublicKeyPath = $this->moduleConfig->getFederationNewCertPath()) && + file_exists($federationNewPublicKeyPath) + ) { + $federationNewJwk = JWKFactory::createFromKeyFile($federationNewPublicKeyPath, null, [ + ClaimsEnum::Kid->value => FingerprintGenerator::forFile($federationNewPublicKeyPath), + ClaimsEnum::Use->value => PublicKeyUseEnum::Signature->value, + ClaimsEnum::Alg->value => $this->moduleConfig->getFederationSigner()->algorithmId(), + ]); + + $keys[] = $federationNewJwk; + } + + $this->federationJwkSet = new JWKSet($keys); + } } diff --git a/tests/unit/src/Services/JsonWebKeySetServiceTest.php b/tests/unit/src/Services/JsonWebKeySetServiceTest.php index 4503ca97..4aca2450 100644 --- a/tests/unit/src/Services/JsonWebKeySetServiceTest.php +++ b/tests/unit/src/Services/JsonWebKeySetServiceTest.php @@ -30,6 +30,9 @@ class JsonWebKeySetServiceTest extends TestCase { private static string $pkGeneratePublic; + private static string $pkGeneratePublicNew; + private static string $pkGeneratePublicFederation; + private static string $pkGeneratePublicFederationNew; /** * @return void @@ -42,15 +45,42 @@ public static function setUpBeforeClass(): void 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); + $pkGenerateNew = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + $pkGenerateFederation = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + $pkGenerateFederationNew = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); // get the public key $pkGenerateDetails = openssl_pkey_get_details($pkGenerate); + $pkGenerateDetailsNew = openssl_pkey_get_details($pkGenerateNew); + $pkGenerateDetailsFederation = openssl_pkey_get_details($pkGenerateFederation); + $pkGenerateDetailsFederationNew = openssl_pkey_get_details($pkGenerateFederationNew); self::$pkGeneratePublic = $pkGenerateDetails['key']; + self::$pkGeneratePublicNew = $pkGenerateDetailsNew['key']; + self::$pkGeneratePublicFederation = $pkGenerateDetailsFederation['key']; + self::$pkGeneratePublicFederationNew = $pkGenerateDetailsFederationNew['key']; file_put_contents(sys_get_temp_dir() . '/oidc_module.crt', self::$pkGeneratePublic); + file_put_contents(sys_get_temp_dir() . '/new_oidc_module.crt', self::$pkGeneratePublicNew); + file_put_contents(sys_get_temp_dir() . '/oidc_module_federation.crt', self::$pkGeneratePublicFederation); + file_put_contents( + sys_get_temp_dir() . '/new_oidc_module_federation.crt', + self::$pkGeneratePublicFederationNew, + ); Configuration::setPreLoadedConfig( - Configuration::loadFromArray([]), + Configuration::loadFromArray([ + ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new_oidc_module.crt', + ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new_oidc_module_federation.crt', + ]), ModuleConfig::DEFAULT_FILE_NAME, ); } @@ -62,13 +92,16 @@ public static function tearDownAfterClass(): void { Configuration::clearInternalState(); unlink(sys_get_temp_dir() . '/oidc_module.crt'); + unlink(sys_get_temp_dir() . '/new_oidc_module.crt'); + unlink(sys_get_temp_dir() . '/oidc_module_federation.crt'); + unlink(sys_get_temp_dir() . '/new_oidc_module_federation.crt'); } /** * @return void * @throws \SimpleSAML\Error\Exception */ - public function testKeys() + public function testProtocolKeys() { $config = [ 'certdir' => sys_get_temp_dir(), @@ -76,13 +109,20 @@ public function testKeys() Configuration::loadFromArray($config, '', 'simplesaml'); $kid = FingerprintGenerator::forString(self::$pkGeneratePublic); - $jwk = JWKFactory::createFromKey(self::$pkGeneratePublic, null, [ 'kid' => $kid, 'use' => 'sig', 'alg' => 'RS256', ]); - $JWKSet = new JWKSet([$jwk]); + + $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicNew); + $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicNew, null, [ + 'kid' => $kidNew, + 'use' => 'sig', + 'alg' => 'RS256', + ]); + + $JWKSet = new JWKSet([$jwk, $jwkNew]); $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); @@ -92,7 +132,7 @@ public function testKeys() /** * @throws \SimpleSAML\Error\Exception */ - public function testCertificationFileNotFound(): void + public function testProtocolCertificateFileNotFound(): void { $this->expectException(Exception::class); $this->expectExceptionMessageMatches('/OIDC protocol public key file does not exists/'); @@ -104,4 +144,32 @@ public function testCertificationFileNotFound(): void new JsonWebKeySetService(new ModuleConfig()); } + + public function testFederationKeys(): void + { + $config = [ + 'certdir' => sys_get_temp_dir(), + ]; + Configuration::loadFromArray($config, '', 'simplesaml'); + + $kid = FingerprintGenerator::forString(self::$pkGeneratePublicFederation); + $jwk = JWKFactory::createFromKey(self::$pkGeneratePublicFederation, null, [ + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + ]); + + $kidNew = FingerprintGenerator::forString(self::$pkGeneratePublicFederationNew); + $jwkNew = JWKFactory::createFromKey(self::$pkGeneratePublicFederationNew, null, [ + 'kid' => $kidNew, + 'use' => 'sig', + 'alg' => 'RS256', + ]); + + $JWKSet = new JWKSet([$jwk, $jwkNew]); + + $jsonWebKeySetService = new JsonWebKeySetService(new ModuleConfig()); + + $this->assertEquals($JWKSet->all(), $jsonWebKeySetService->federationKeys()); + } }