diff --git a/.env b/.env index 9cfb3086..7924cf60 100644 --- a/.env +++ b/.env @@ -50,6 +50,8 @@ MESSENGER_TRANSPORT_DSN_FAILED='doctrine://default?queue_name=failed' AZURE_AZ_OIDC_METADATA_URL=AZURE_AZ_APP_METADATA_URL AZURE_AZ_OIDC_CLIENT_ID=AZURE_AZ_APP_CLIENT_ID AZURE_AZ_OIDC_CLIENT_SECRET=AZURE_AZ_APP_CLIENT_SECRET +# Date the Azure client secret expires (any strtotime-parseable value) +AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT=2027-01-31 AZURE_AZ_OIDC_REDIRECT_URI=AZURE_AZ_APP_REDIRECT_URI AZURE_AZ_OIDC_ALLOW_HTTP=false AZURE_AZ_OIDC_LEEWAY=10 diff --git a/.env.test b/.env.test index 022bf682..af091794 100644 --- a/.env.test +++ b/.env.test @@ -4,3 +4,8 @@ APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots + +# The path of this URI is the only path the OIDC authenticator treats as a +# callback (openid-connect-bundle 6.0), so it has to be the app's own callback +# route for a callback to reach the authenticator at all. +AZURE_AZ_OIDC_REDIRECT_URI=https://itksites.example.org/openid-connect/generic diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ada776..c4efbaf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `guzzlehttp/guzzle` 7.10.6 → 7.15.5, `guzzlehttp/psr7` 2.10.4 → 2.13.1 - Regenerated the API spec: `symfony/yaml` now writes sequence items on their own line. No API changes +- [#92](https://github.com/itk-dev/devops_itksites/pull/92) Update openid-connect-bundle to 6.0 + - Bump `itk-dev/openid-connect-bundle` to `^6.0` + - A failed OIDC callback now raises an error instead of redirecting to the + identity provider again, so an expired client secret can no longer put the + site in a login loop + - Only the provider's callback path is treated as a callback; the configured + `redirect_uri` covers this, no `callback_path` needed + - Set `client_secret_expires_at` for the `azure_az` provider from the new + `AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT` variable, so the bundle warns + before the secret expires + - Render a failed login as a page saying so, instead of an unhandled + exception, in `OpenIdConnectFailureListener` + - Add an `oidc_client_secret` health check, so a client secret nearing its + expiry shows up in `/health/detail` instead of in a login loop - [#83](https://github.com/itk-dev/devops_itksites/pull/83) 7523: Service agreements - Add Project entity top-level Economics project. - Add CodeOwner entity diff --git a/README.md b/README.md index 5a50f68d..d8e51b32 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,21 @@ read `/health/detail` when it goes red: curl --silent https://itksites.local.itkdev.dk/health/detail | jq ``` -The checks cover the database, the RabbitMQ messenger transport and the -freshness of the most recent detection result. The last one catches an ingest -pipeline that has stopped while the application itself is still serving -requests. +The checks cover the database, the RabbitMQ messenger transport, the freshness +of the most recent detection result and the expiry of the OIDC client secret. +The freshness check catches an ingest pipeline that has stopped while the +application itself is still serving requests. The client secret check catches +the expiry that breaks every login at once. `HEALTH_INGEST_MAX_AGE` sets how old the most recent detection result may be before ingest is reported as degraded. +`AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT` is where the client secret check reads +the date. It reports degraded only once that date has passed, so watch +`days_remaining` in the detail payload rather than waiting for it to go red. With +no date configured the check reports skipped, which means nothing is watching the +secret. + Results are cached for `HEALTH_CACHE_TTL` seconds so that polling does not turn into load on the dependencies. The cache is the dedicated, filesystem-backed `cache.health` pool in `config/packages/cache.yaml` – it has to keep working diff --git a/composer.json b/composer.json index cf7804a7..fdfdcc75 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.0", "easycorp/easyadmin-bundle": "^5.0", - "itk-dev/openid-connect-bundle": "^5.0", + "itk-dev/openid-connect-bundle": "^6.1", "itk-dev/vault-bundle": "^1.0.0", "nelmio/cors-bundle": "^2.2", "ocramius/doctrine-batch-utils": "^2.8", diff --git a/composer.lock b/composer.lock index 436ebc9d..4c17a85c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "896639a35d4c350fd3b2b3fdfe6542fc", + "content-hash": "64cb1b11b5e4c3da1bdc1f83e64b414f", "packages": [ { "name": "api-platform/core", @@ -1928,31 +1928,31 @@ }, { "name": "itk-dev/openid-connect", - "version": "5.0.0", + "version": "5.1.0", "source": { "type": "git", "url": "https://github.com/itk-dev/openid-connect.git", - "reference": "f241f6794a2e74eab8c4808bc22f341a98e96f0b" + "reference": "950758b97397ab529f1df787c377ca54b321af79" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itk-dev/openid-connect/zipball/f241f6794a2e74eab8c4808bc22f341a98e96f0b", - "reference": "f241f6794a2e74eab8c4808bc22f341a98e96f0b", + "url": "https://api.github.com/repos/itk-dev/openid-connect/zipball/950758b97397ab529f1df787c377ca54b321af79", + "reference": "950758b97397ab529f1df787c377ca54b321af79", "shasum": "" }, "require": { "ext-json": "*", "ext-openssl": "*", "firebase/php-jwt": "^7.0", - "league/oauth2-client": "^2.6", + "league/oauth2-client": "^2.8.1", "php": "^8.3", "psr/cache": "^2.0 || ^3.0", - "psr/http-client": "^1.0", - "robrichards/xmlseclibs": "^3.1.5" + "psr/http-client": "^1.0" }, "require-dev": { "ergebnis/composer-normalize": "^2.50", "friendsofphp/php-cs-fixer": "^3.75", + "infection/infection": "^0.35.2", "mockery/mockery": "^1.6.12", "phpstan/phpstan": "^2.1.41", "phpstan/phpstan-mockery": "^2.0", @@ -1986,34 +1986,33 @@ "description": "OpenID connect configuration package", "support": { "issues": "https://github.com/itk-dev/openid-connect/issues", - "source": "https://github.com/itk-dev/openid-connect/tree/5.0.0" + "source": "https://github.com/itk-dev/openid-connect/tree/5.1.0" }, - "time": "2026-06-02T09:01:56+00:00" + "time": "2026-08-26T12:01:21+00:00" }, { "name": "itk-dev/openid-connect-bundle", - "version": "5.1.1", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/itk-dev/openid-connect-bundle.git", - "reference": "5021974bb605d3c5130b06e05bd0a277587277bc" + "reference": "c990bd64fca599de02766671b99eab4cc260915b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itk-dev/openid-connect-bundle/zipball/5021974bb605d3c5130b06e05bd0a277587277bc", - "reference": "5021974bb605d3c5130b06e05bd0a277587277bc", + "url": "https://api.github.com/repos/itk-dev/openid-connect-bundle/zipball/c990bd64fca599de02766671b99eab4cc260915b", + "reference": "c990bd64fca599de02766671b99eab4cc260915b", "shasum": "" }, "require": { "doctrine/orm": "^2.8 || ^3.0", "ext-json": "*", "ext-openssl": "*", - "itk-dev/openid-connect": "^5.0", + "itk-dev/openid-connect": "^5.1", "php": "^8.3", "psr/log": "^3.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/clock": "^6.4 || ^7.0 || ^8.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", "symfony/framework-bundle": "^6.4.13 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4.13 || ^7.0 || ^8.0", @@ -2023,6 +2022,7 @@ "require-dev": { "ergebnis/composer-normalize": "^2.28", "friendsofphp/php-cs-fixer": "^3.11", + "igor-php/igor-php": "^0.9", "infection/infection": "*", "phpstan/phpstan": "^2.1.41", "phpstan/phpstan-deprecation-rules": "^2.0", @@ -2056,9 +2056,9 @@ "description": "Symfony bundle for openid-connect", "support": { "issues": "https://github.com/itk-dev/openid-connect-bundle/issues", - "source": "https://github.com/itk-dev/openid-connect-bundle/tree/5.1.1" + "source": "https://github.com/itk-dev/openid-connect-bundle/tree/6.1.0" }, - "time": "2026-08-19T13:41:15+00:00" + "time": "2026-08-26T14:22:26+00:00" }, { "name": "itk-dev/vault", @@ -3150,48 +3150,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "robrichards/xmlseclibs", - "version": "3.1.5", - "source": { - "type": "git", - "url": "https://github.com/robrichards/xmlseclibs.git", - "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", - "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "php": ">= 5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "RobRichards\\XMLSecLibs\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "A PHP library for XML Security", - "homepage": "https://github.com/robrichards/xmlseclibs", - "keywords": [ - "security", - "signature", - "xml", - "xmldsig" - ], - "support": { - "issues": "https://github.com/robrichards/xmlseclibs/issues", - "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" - }, - "time": "2026-03-13T10:31:56+00:00" - }, { "name": "symfony/amqp-messenger", "version": "v8.1.0", diff --git a/config/packages/itkdev_openid_connect.yaml b/config/packages/itkdev_openid_connect.yaml index 854a6d3e..61943447 100644 --- a/config/packages/itkdev_openid_connect.yaml +++ b/config/packages/itkdev_openid_connect.yaml @@ -15,6 +15,9 @@ itkdev_openid_connect: metadata_url: '%env(string:AZURE_AZ_OIDC_METADATA_URL)%' client_id: '%env(AZURE_AZ_OIDC_CLIENT_ID)%' client_secret: '%env(AZURE_AZ_OIDC_CLIENT_SECRET)%' + # Date the client secret expires. Lets the bundle warn before it + # breaks every login, as it did on 2026-08-12. + client_secret_expires_at: '%env(string:AZURE_AZ_OIDC_CLIENT_SECRET_EXPIRES_AT)%' # Specify redirect URI redirect_uri: '%env(string:AZURE_AZ_OIDC_REDIRECT_URI)%' allow_http: '%env(bool:AZURE_AZ_OIDC_ALLOW_HTTP)%' diff --git a/config/reference.php b/config/reference.php index df7802b5..db69eea8 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1768,12 +1768,15 @@ * metadata_url?: scalar|Param|null, // URL to OpenId Discovery Document * client_id?: scalar|Param|null, // Client ID assigned by authorizer * client_secret?: scalar|Param|null, // Client secret/password assigned by authorizer - * client_secret_expires_at?: scalar|Param|null, // Date the client secret expires, e.g. "2027-01-31". Anything strtotime() understands, and usually an environment variable. An expired secret breaks every login, so configuring this lets the bundle warn while there is still time to rotate. Will be required in 6.0. // Default: null + * client_secret_expires_at?: scalar|Param|null, // Optional. Date the client secret expires, e.g. "2027-01-31". Anything strtotime() understands, and usually an environment variable. Set it and the bundle warns before the secret expires; leave it unset and the provider reports "unknown" and is not monitored. Set it where the real secret lives — a date carried in a committed default is a date nobody maintains. * leeway?: int|Param, // Leeway in seconds to account for clock skew between server and provider // Default: 10 * cache_duration?: int|Param, // Cache duration in seconds for the OIDC discovery document and JWKS (default: 86400 — 24 hours) // Default: 86400 + * scopes?: Param|string|list, + * pkce?: bool|Param, // Send a PKCE challenge (RFC 7636, S256) with the authorization request // Default: true * redirect_uri?: scalar|Param|null, // Redirect URI registered at identity provider * redirect_route?: scalar|Param|null, // Redirect route registered at identity provider (must not be set if redirect_uri is set) * redirect_route_parameters?: array, + * callback_path?: scalar|Param|null, // Optional. The request path the callback arrives on, for a proxy that rewrites it without sending X-Forwarded-Prefix. Include any base path. Defaults to the path of redirect_uri, or of the generated redirect_route; a trusted X-Forwarded-Prefix or a subdirectory deployment is already accounted for without this. * allow_http?: bool|Param, // Whether to allow http or not (default: false) // Default: false * http_client_options?: array{ // Options forwarded to the underlying Guzzle HTTP client. league/oauth2-client only forwards: timeout, proxy, verify (verify is only consulted when proxy is set). * timeout?: float|Param, // Total request timeout in seconds. Defaults to 30; set to 0 to wait indefinitely (Guzzle's own default). // Default: 30.0 diff --git a/src/EventListener/OpenIdConnectFailureListener.php b/src/EventListener/OpenIdConnectFailureListener.php new file mode 100644 index 00000000..f2338854 --- /dev/null +++ b/src/EventListener/OpenIdConnectFailureListener.php @@ -0,0 +1,82 @@ +getThrowable(); + + if (!$event->isMainRequest() || !$exception instanceof AuthenticationFailedException) { + return; + } + + // Deliberately without the exception message: it carries the identity + // provider's own error text, which belongs in the log, not in a browser. + // `declined` is the one thing worth telling the user, and it is the error + // code rather than that text. + $content = $this->twig->render('error/openid_connect_failed.html.twig', [ + 'login_url' => $this->urlGenerator->generate('itkdev_openid_connect_login', ['providerKey' => 'azure_az']), + 'declined' => $exception instanceof ProviderErrorException + && ProviderErrorException::ACCESS_DENIED === $exception->getError(), + ]); + + // A refusal states its own status — 403 where the user or a policy said + // no, 503 where Azure reports its own trouble. Everything else is a 500: + // the likely cause is on this side of the login, and it should read as an + // error in the log and in monitoring. + $status = $exception instanceof HttpExceptionInterface + ? $exception->getStatusCode() + : Response::HTTP_INTERNAL_SERVER_ERROR; + + $response = new Response($content, $status); + $response->headers->set('Cache-Control', 'no-store, private'); + + $event->setResponse($response); + } +} diff --git a/src/Health/Check/ClientSecretExpiryHealthCheck.php b/src/Health/Check/ClientSecretExpiryHealthCheck.php new file mode 100644 index 00000000..ca0f56c6 --- /dev/null +++ b/src/Health/Check/ClientSecretExpiryHealthCheck.php @@ -0,0 +1,85 @@ +expiryChecker->getAllStatuses(); + + if ([] === $statuses) { + return HealthCheckResult::skipped($this->getName(), 'No OIDC providers are configured.'); + } + + $details = []; + $expired = []; + $unknown = []; + + foreach ($statuses as $providerKey => $expiry) { + $details[$providerKey.'.status'] = $expiry->status->value; + $details[$providerKey.'.expires_at'] = $expiry->expiresAt?->format(\DATE_ATOM); + $details[$providerKey.'.days_remaining'] = $expiry->daysRemaining; + + if ($expiry->isExpired()) { + $expired[] = $providerKey; + } elseif (ClientSecretExpiryStatus::Unknown === $expiry->status) { + $unknown[] = $providerKey; + } + } + + if ([] !== $expired) { + return HealthCheckResult::degraded( + $this->getName(), + \sprintf('Client secret past its configured expiry: %s.', implode(', ', $expired)), + $details + ); + } + + // No date anywhere is the state an installation is in before it + // configures any: nothing is being monitored, which is not the same as + // nothing being wrong, so it is reported as skipped rather than ok. + if (\count($unknown) === \count($statuses)) { + return HealthCheckResult::skipped($this->getName(), 'No client secret expiry dates are configured.'); + } + + return HealthCheckResult::ok($this->getName(), $details); + } +} diff --git a/src/Security/AzureOIDCAuthenticator.php b/src/Security/AzureOIDCAuthenticator.php index d2ab5d12..3d6198b1 100644 --- a/src/Security/AzureOIDCAuthenticator.php +++ b/src/Security/AzureOIDCAuthenticator.php @@ -63,7 +63,10 @@ public function authenticate(Request $request): Passport return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier())); } catch (OpenIdConnectExceptionInterface $exception) { - throw new CustomUserMessageAuthenticationException($exception->getMessage()); + // Chained: the bundle reads the cause back in onAuthenticationFailure() + // to decide what the user is shown. Dropping it turns a refusal the + // user caused into an unexplained 500. + throw new CustomUserMessageAuthenticationException($exception->getMessage(), previous: $exception); } } diff --git a/templates/error/openid_connect_failed.html.twig b/templates/error/openid_connect_failed.html.twig new file mode 100644 index 00000000..1141b94a --- /dev/null +++ b/templates/error/openid_connect_failed.html.twig @@ -0,0 +1,78 @@ +{# + Shown by App\EventListener\OpenIdConnectFailureListener when an OIDC login + could not be completed. + + Self-contained on purpose: an error page should not depend on the asset + build, and "Try again" is a link the user clicks rather than a redirect — + the bundle stopped redirecting to the identity provider precisely to end + the loop a broken login used to cause. +#} + + + + + + + + {% if declined %}Login declined{% else %}Login failed{% endif %} - ITKsites + + + + + +
+

ITKsites

+ + {% if declined %} +

+ The login was not completed. +

+ +

+ This happens if you cancelled at the login screen, or if your session + there had expired — in either case try again. +

+ {% else %} +

+ We could not complete your login. +

+ +

+ This happens if the login was left half-finished for too long — in that + case try again. +

+ +

+ If it keeps failing, the login setup itself needs attention: tell ITK Dev, + and mention the time you tried. The details are in the log. +

+ {% endif %} +
+ + diff --git a/templates/post_logout/index.html.twig b/templates/post_logout/index.html.twig index 025585e4..2adcb81b 100644 --- a/templates/post_logout/index.html.twig +++ b/templates/post_logout/index.html.twig @@ -101,7 +101,7 @@ diff --git a/tests/Controller/HealthControllerTest.php b/tests/Controller/HealthControllerTest.php index b8adf86d..2bb63a17 100644 --- a/tests/Controller/HealthControllerTest.php +++ b/tests/Controller/HealthControllerTest.php @@ -77,7 +77,7 @@ public function testDetailReportsEveryCheck(): void $this->assertArrayHasKey('checks', $payload); $this->assertEqualsCanonicalizing( - ['database', 'rabbitmq', 'ingest_freshness'], + ['database', 'rabbitmq', 'ingest_freshness', 'oidc_client_secret'], array_keys($payload['checks']) ); diff --git a/tests/EventListener/OpenIdConnectFailureListenerTest.php b/tests/EventListener/OpenIdConnectFailureListenerTest.php new file mode 100644 index 00000000..d41cf564 --- /dev/null +++ b/tests/EventListener/OpenIdConnectFailureListenerTest.php @@ -0,0 +1,123 @@ +request('GET', '/openid-connect/generic?state=bogus&code=bogus'); + + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('could not complete your login', $content); + $this->assertStringContainsString('/openidconnect/login/azure_az', $content); + } + + /** + * The identity provider's own error text says why a login failed and + * belongs in the log. The page says none of it. + */ + public function testErrorPageDisclosesNothingAboutTheFailure(): void + { + $client = static::createClient(); + $client->request('GET', '/openid-connect/generic?state=bogus&code=bogus'); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringNotContainsString('Error occurred validating openid login', $content); + $this->assertStringContainsString( + 'no-store', + (string) $client->getResponse()->headers->get('Cache-Control'), + ); + } + + /** + * A refusal is not a server error. The user declined, or Azure declined on a + * policy, and answering 500 pages somebody for an ordinary outcome. + */ + public function testARefusedLoginAnswers403(): void + { + $client = static::createClient(); + $session = $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state='.$session.'&error=access_denied&error_description=User+cancelled'); + + $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + $this->assertNull( + $client->getResponse()->headers->get('Location'), + 'A refused login was answered with a redirect: the firewall re-entered its entry point and the loop is back.' + ); + } + + /** + * The page tells the user the login was declined rather than that something + * went wrong, and still repeats nothing the identity provider sent. + */ + public function testARefusedLoginSaysItWasDeclined(): void + { + $client = static::createClient(); + $session = $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state='.$session.'&error=access_denied&error_description=User+cancelled'); + + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('Login declined', $content); + $this->assertStringContainsString('cancelled at the login screen', $content); + $this->assertStringContainsString('/openidconnect/login/azure_az', $content); + $this->assertStringNotContainsString('access_denied', $content); + $this->assertStringNotContainsString('User cancelled', $content); + } + + /** + * A forged callback carries whatever text its sender chose. The state is + * checked first, so none of it is trusted and the answer is the ordinary + * failure page. + */ + public function testAForgedRefusalIsAnOrdinaryFailure(): void + { + $client = static::createClient(); + $this->startedLogin($client); + + $client->request('GET', '/openid-connect/generic?state=does-not-match&error=access_denied&error_description=Call+0800+SCAM'); + + $this->assertResponseStatusCodeSame(Response::HTTP_INTERNAL_SERVER_ERROR); + $content = (string) $client->getResponse()->getContent(); + $this->assertStringContainsString('could not complete your login', $content); + $this->assertStringNotContainsString('SCAM', $content); + } + + /** + * Put the session values a login leaves behind in place, and return the state + * the callback has to carry to be recognised as belonging to it. + */ + private function startedLogin(KernelBrowser $client): string + { + $session = static::getContainer()->get('session.factory')->createSession(); + $session->set('oauth2provider', 'azure_az'); + $session->set('oauth2state', 'the-real-state'); + $session->set('oauth2nonce', 'the-real-nonce'); + $session->set('oauth2pkce_verifier', 'the-real-verifier'); + $session->save(); + + $client->getCookieJar()->set(new Cookie($session->getName(), $session->getId())); + + return 'the-real-state'; + } +} diff --git a/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php b/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php new file mode 100644 index 00000000..3ad226b2 --- /dev/null +++ b/tests/Health/Check/ClientSecretExpiryHealthCheckTest.php @@ -0,0 +1,109 @@ +check(['azure_az' => '2026-08-01']); + + self::assertSame(HealthStatus::Degraded, $result->status); + self::assertSame('Client secret past its configured expiry: azure_az.', $result->message); + self::assertSame('expired', $result->details['azure_az.status']); + self::assertLessThan(0, $result->details['azure_az.days_remaining']); + } + + /** + * Expiring soon stays ok. Thirty days of 503 would train everyone to ignore + * the endpoint before the day it matters; the remaining days are in the + * payload for whatever watches it. + */ + public function testExpiringSoonIsOkWithDaysRemaining(): void + { + $result = $this->check(['azure_az' => '2026-09-05']); + + self::assertSame(HealthStatus::Ok, $result->status); + self::assertSame('expiring_soon', $result->details['azure_az.status']); + // 2026-08-26 12:00 UTC to midnight on 2026-09-05 is 9.5 days, floored. + self::assertSame(9, $result->details['azure_az.days_remaining']); + } + + public function testHealthySecretIsOk(): void + { + $result = $this->check(['azure_az' => '2027-01-31']); + + self::assertSame(HealthStatus::Ok, $result->status); + self::assertSame('ok', $result->details['azure_az.status']); + self::assertSame('2027-01-31T00:00:00+00:00', $result->details['azure_az.expires_at']); + } + + /** + * An unconfigured date means nothing is being monitored, which must not read + * as healthy and must not fail readiness either. + */ + public function testUnconfiguredDateIsSkipped(): void + { + $result = $this->check(['azure_az' => null]); + + self::assertSame(HealthStatus::Skipped, $result->status); + self::assertSame('No client secret expiry dates are configured.', $result->message); + } + + public function testNoProvidersIsSkipped(): void + { + $result = $this->check([]); + + self::assertSame(HealthStatus::Skipped, $result->status); + self::assertSame('No OIDC providers are configured.', $result->message); + } + + /** + * One expired provider degrades the check even when another is fine, and + * both stay visible in the payload. + */ + public function testExpiredProviderDegradesAlongsideAHealthyOne(): void + { + $result = $this->check(['azure_az' => '2027-01-31', 'legacy' => '2026-08-01']); + + self::assertSame(HealthStatus::Degraded, $result->status); + self::assertSame('Client secret past its configured expiry: legacy.', $result->message); + self::assertSame('ok', $result->details['azure_az.status']); + self::assertSame('expired', $result->details['legacy.status']); + } + + /** + * @param array $expiryDates + */ + private function check(array $expiryDates): HealthCheckResult + { + $checker = new ClientSecretExpiryChecker( + new MockClock('2026-08-26 12:00:00', 'UTC'), + $expiryDates, + self::WARNING_DAYS, + new NullLogger(), + ); + + return (new ClientSecretExpiryHealthCheck($checker))->check(); + } +}