Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
984966b
Merge tag '4.1.2' into develop
turegjorup May 11, 2026
4f0f6ed
Introduce marker-interface exception contract (5.0 — BREAKING)
turegjorup May 11, 2026
4df22f6
Justify @phpstan-ignore and PSR-18 transport stub in tests
turegjorup May 12, 2026
fcbb5b3
Enforce @phpstan-ignore-must-have-comment via PHPStan built-in
turegjorup May 12, 2026
4c8916e
Merge pull request #40 from itk-dev/feature/exception-contract-5.0
turegjorup May 12, 2026
baab00a
Drop nullable from $provider test property
turegjorup May 12, 2026
02d8779
Install phpstan/phpstan-mockery for Mockery type stubs
turegjorup May 12, 2026
f47f708
Narrow validateIdToken claim accesses via @var
turegjorup May 12, 2026
eda1395
Fail loudly on missing fixtures and malformed authorization URLs
turegjorup May 12, 2026
32aa3e6
Clear remaining level-8 test issues
turegjorup May 12, 2026
c865421
Delete now-empty phpstan-baseline.neon
turegjorup May 12, 2026
54be58b
Fix CI failures: redundant PHPDoc, composer order, CHANGELOG
turegjorup May 12, 2026
1774bc0
Merge pull request #41 from itk-dev/fix/phpstan-test-baseline-cleanup
turegjorup May 12, 2026
471620c
Replace (string) casts on JSON payload values with is_string guards
turegjorup May 12, 2026
a320df6
Throw JsonException for malformed discovery document, not CacheException
turegjorup May 12, 2026
39ac2f8
Introduce MetadataException for malformed OIDC discovery documents
turegjorup May 12, 2026
c715359
Type constructor $options / $collaborators array shapes
turegjorup May 12, 2026
38391f9
Narrow JSON payload accesses in getJwtVerificationKeys and getIdToken
turegjorup May 12, 2026
6dae797
Document, rename, and tighten the exception system
turegjorup May 12, 2026
6515c73
Narrow validateIdToken claims and assorted test types
turegjorup May 12, 2026
44dfae9
Merge pull request #42 from itk-dev/fix/replace-string-casts-with-guards
turegjorup May 12, 2026
ff00903
Merge pull request #43 from itk-dev/feature/typed-constructor-options
turegjorup May 12, 2026
425c75b
Merge pull request #44 from itk-dev/feature/json-payload-narrowing
turegjorup May 12, 2026
9ef1860
Fix markdown-lint MD024: merge duplicate Documentation sections
turegjorup May 12, 2026
820dc08
Merge pull request #45 from itk-dev/feature/phpstan-max-completion
turegjorup May 12, 2026
716bcfa
Collapse multi-line @param/@return descriptions onto single lines
turegjorup May 12, 2026
9f370c6
Merge pull request #46 from itk-dev/feature/phpdoc-align-left
turegjorup May 12, 2026
ebfc625
Bump PHPStan to level: max
turegjorup May 12, 2026
810e141
Merge pull request #47 from itk-dev/feature/phpstan-level-max
turegjorup May 12, 2026
4076c79
Cut 5.0.0 release entry and add UPGRADE-5.0.md migration guide
turegjorup May 12, 2026
e9f50e6
Set 5.0.0 release date to 2026-06-02
turegjorup Jun 2, 2026
043734e
Install dependencies before composer audit in CI
turegjorup Jun 2, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/composer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,5 @@ jobs:
docker network create frontend

- run: |
docker compose run --rm phpfpm composer install
docker compose run --rm phpfpm composer audit
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ phpcs-report.xml
.php-cs-fixer.cache
coverage/
yarn.lock

# Per-developer Claude Code context — local tooling, not part of the public source.
/CLAUDE.md
67 changes: 66 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.0.0] - 2026-06-02

Reworked exception hierarchy and tightened IdP-payload validations. The runtime
behaviour is unchanged for spec-compliant IdPs — see [UPGRADE-5.0.md](UPGRADE-5.0.md)
for the consumer migration guide.

### Changed (BREAKING)

- Reworked exception hierarchy around the new
`OpenIdConnectExceptionInterface` marker. Concrete exception classes now extend the
SPL type that best describes the failure category (`\RuntimeException`,
`\LogicException`, `\InvalidArgumentException`) instead of the abstract
`ItkOpenIdConnectException`. Existing `catch (ItkOpenIdConnectException $e)` blocks
will not match anything thrown by 5.0+ code — catch the marker, or scope to a more
specific concrete / SPL parent
- Renamed `KeyException` → `JwksException` for symmetry with the new
`MetadataException` and to better describe its scope (the type fires for both
JWKS-document-level and JWK-entry-level errors)
- `OpenIdConfigurationProvider::__construct` now throws the typed
`ConfigurationException` (still extending `\InvalidArgumentException`) instead of
a raw `\InvalidArgumentException` for missing required options
- New typed throws replace 4.x silent coercions: malformed JWKS payload
(missing `keys` array, non-object JWK entry, missing/non-string `kid` /
`kty` / RSA `e` / `n`, unsupported `kty`) → `JwksException`; malformed
OIDC discovery document → `MetadataException`; token endpoint response
missing string `id_token` → `CodeException`
- `OpenIdConfigurationProvider::getIdToken` narrowed its boundary `catch` from
`\Exception` to the three actually-thrown families
(`IdentityProviderException|ClientExceptionInterface|\JsonException`).
Exceptions from the upstream `getConfiguration('token_endpoint')` call
(`CacheException`, `HttpException`, `MetadataException`, library
`JsonException`) now propagate as themselves rather than being re-wrapped
as `CodeException`

### Added

- Marker interface `OpenIdConnectExceptionInterface` (extends `\Throwable`)
- Concrete exceptions `ConfigurationException` and `MetadataException`
- `tests/Exception/ExceptionHierarchyTest.php` locks the contract: every concrete
implements the marker, extends the correct SPL parent, and is caught by a single
`catch (OpenIdConnectExceptionInterface $e)` block

### Deprecated

- Abstract `ItkOpenIdConnectException` — catch `OpenIdConnectExceptionInterface`
instead. Kept through 5.x as a documented alias that still implements the marker;
removal scheduled for 6.0

### Documentation

- Added an "Exception handling" section to `README.md` covering the marker
interface, SPL parents, PSR-18 co-implementation on `HttpException`, and the
4.x → 5.0 catch-block migration
- Class-level PHPDoc on every concrete exception describing its trigger sites and
the boundary against related types

### Tooling

- PHPStan bumped to `level: max` (was 8). Scans `src/` + `tests/`
- `reportIgnoresWithoutComments: true` so unexplained `@phpstan-ignore` directives
fail CI
- Added `phpstan/phpstan-mockery` to `require-dev` for stubs covering Mockery's
fluent `shouldReceive(...)->andReturn(...)` API

## [4.1.2] - 2026-05-11

- Chained `previous` consistently in `OpenIdConfigurationProvider` catch
Expand Down Expand Up @@ -163,7 +227,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- This CHANGELOG file to hopefully serve as an evolving example of a
standardized open source project CHANGELOG.

[Unreleased]: https://github.com/itk-dev/openid-connect/compare/4.1.2...HEAD
[Unreleased]: https://github.com/itk-dev/openid-connect/compare/5.0.0...HEAD
[5.0.0]: https://github.com/itk-dev/openid-connect/compare/4.1.2...5.0.0
[4.1.2]: https://github.com/itk-dev/openid-connect/compare/4.1.1...4.1.2
[4.1.1]: https://github.com/itk-dev/openid-connect/compare/4.1.0...4.1.1
[4.1.0]: https://github.com/itk-dev/openid-connect/compare/4.0.3...4.1.0
Expand Down
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,58 @@ if (!$sessionState || $request->query->get('state') !== $sessionState) {

// Validate the id token. This will validate the token against the keys published by the
// provider (Azure AD B2C). If the token is invalid or the nonce doesn't match an
// exception will thrown.
// exception will be thrown.
try {
$claims = $provider->validateIdToken($request->query->get('id_token'), $session->get('oauth2nonce'));
// Authentication successful
} catch (ItkOpenIdConnectException $exception) {
} catch (OpenIdConnectExceptionInterface $exception) {
// Handle failed authentication
} finally {
$this->session->remove('oauth2nonce');
}
```

### Exception handling

Every exception thrown from a public method of this library implements
`\ItkDev\OpenIdConnect\Exception\OpenIdConnectExceptionInterface`. Catch the
marker to handle any OIDC failure with a single block, or scope to a more
specific type when you need to discriminate:

```php
use ItkDev\OpenIdConnect\Exception\OpenIdConnectExceptionInterface;

try {
$claims = $provider->validateIdToken($idToken, $nonce);
} catch (OpenIdConnectExceptionInterface $e) {
// Cause is preserved via $e->getPrevious()
}
```

Concrete exception classes extend the SPL type that describes the failure
category, so a `catch` block scoped to that SPL type will also match:

| SPL parent | Concrete types | Category |
| ---------- | -------------- | -------- |
| `\RuntimeException` | `CacheException`, `HttpException`, `JsonException`, `DecodeException`, `JwksException`, `CodeException`, `ValidationException`, `ClaimsException`, `MetadataException` | Network, cache, token validation, claims mismatch — transient or data-shape failures |
| `\LogicException` | `BadUrlException`, `IllegalSchemeException`, `MissingParameterException` | Programmer/config bugs — should be fixed in code |
| `\InvalidArgumentException` | `ConfigurationException`, `NegativeCacheDurationException`, `NegativeLeewayException` | Invalid input to the constructor / setters |

`HttpException` additionally implements PSR-18's
`Psr\Http\Client\ClientExceptionInterface`, so existing PSR-18-aware
consumers can keep catching on the standard PSR marker.

Every wrap site preserves the underlying cause via `$previous`, so
`$e->getPrevious()` walks back to the originating Guzzle, firebase/php-jwt
or PSR-6 cache exception.

> **Upgrading from 4.x:** the concrete exceptions no longer extend the
> abstract `ItkOpenIdConnectException`. Catches written as
> `catch (ItkOpenIdConnectException $e)` will not match anything thrown
> by 5.0+ code — migrate to `catch (OpenIdConnectExceptionInterface $e)`.
> The abstract class itself is kept through 5.x as a documented alias
> (`@deprecated`); removal is scheduled for 6.0.

## Development Setup

A `docker-compose.yml` file with a PHP 8.3+ image is included in this project.
Expand Down
149 changes: 149 additions & 0 deletions UPGRADE-5.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Upgrading from 4.x to 5.0

5.0 reworks the exception hierarchy and tightens several IdP-payload
validations. The runtime behaviour is unchanged for spec-compliant IdPs
— this document covers the consumer-visible API changes you'll need to
adjust catch blocks for.

## Catch the marker interface, not the abstract

Concrete exception classes no longer extend
`\ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException`. Existing
catches against the abstract will not match anything thrown by 5.0+
code:

```diff
- } catch (\ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException $e) {
+ } catch (\ItkDev\OpenIdConnect\Exception\OpenIdConnectExceptionInterface $e) {
```

The abstract class is kept through the 5.x line as a `@deprecated`
alias that still implements the marker; removal is scheduled for 6.0.

A consumer that needs to discriminate by failure category can scope to
the concrete's SPL parent instead — catching `\RuntimeException`
matches every transient/data failure (network, cache, token
validation, claims mismatch); catching `\LogicException` matches the
programmer-error variants (`BadUrlException`,
`IllegalSchemeException`, `MissingParameterException`); catching
`\InvalidArgumentException` matches the invalid-constructor-input
variants (`ConfigurationException`, `NegativeCacheDurationException`,
`NegativeLeewayException`).

## Catch on `\InvalidArgumentException` still works for constructor errors

`OpenIdConfigurationProvider::__construct` previously threw raw
`\InvalidArgumentException` for missing required options. It now
throws the typed `ConfigurationException`, which still **extends**
`\InvalidArgumentException`. Existing catches at the SPL level
continue to match without any change:

```php
try {
new OpenIdConfigurationProvider([]);
} catch (\InvalidArgumentException $e) { // still catches in 5.0
// ...
}
```

## New typed throws where 4.x silently coerced

Three sites that previously cast malformed IdP-returned values to
strings now throw a typed exception:

- **Malformed JWKS payload (`keys` array missing, JWK entry without
string `kid` / `kty` / `e` / `n`)** → `JwksException`. 4.x silently
built a degraded `Key` from whatever the (string) cast produced, or
tripped a downstream type error in `XMLSecurityKey::convertRSA`.
- **OIDC discovery doc with missing / non-string required key** →
`MetadataException` (was bubbled as `CacheException`, semantically
misleading — the failure is the IdP-returned payload not
conforming to the OIDC Discovery spec, not the cache layer
misbehaving).
- **Token endpoint response missing string `id_token`** →
`CodeException` (was a return of `mixed` that produced confusing
errors at the call site).

If you've been catching `CacheException` specifically for the
missing-config-key case, you'll need to widen to the marker interface
or to `MetadataException`:

```diff
try {
$provider->getBaseAuthorizationUrl();
- } catch (\ItkDev\OpenIdConnect\Exception\CacheException $e) {
+ } catch (\ItkDev\OpenIdConnect\Exception\MetadataException $e) {
// Discovery document is malformed
}
```

`CacheException` still fires for PSR-6 cache-layer failures (its
strictly-cache-only meaning in 5.0).

## `getIdToken`'s `@throws` no longer advertises `ClientExceptionInterface`

The previous `@throws ClientExceptionInterface` declaration on
`getIdToken` documented a dead-code path — the body's catch-all
wrapped the transport exception into `CodeException` before it could
surface. The declaration was removed in 4.1.2 already; the 5.0 boundary
catch is now narrowed to
`IdentityProviderException|ClientExceptionInterface|\JsonException`
explicitly, but the public type returned to the caller remains
`CodeException` (with the cause chained via `$previous`).

If you were catching `ClientExceptionInterface` after a `getIdToken`
call to handle transport failures specifically, switch to catching
`CodeException` and inspect `$e->getPrevious()` for the original
PSR-18 exception:

```diff
try {
$idToken = $provider->getIdToken($code);
- } catch (\Psr\Http\Client\ClientExceptionInterface $e) {
- // transport failure
+ } catch (\ItkDev\OpenIdConnect\Exception\CodeException $e) {
+ $transport = $e->getPrevious();
+ if ($transport instanceof \Psr\Http\Client\ClientExceptionInterface) {
+ // transport failure
+ }
}
```

(`HttpException` — fired by the *discovery* / JWKS fetches, not the
token-exchange POST — still implements `ClientExceptionInterface` as
part of its public contract.)

## Catch-on-SPL semantic change

The re-parenting onto SPL types means catches on `\RuntimeException`,
`\LogicException`, or `\InvalidArgumentException` now match OIDC
failures where they previously did not. Concretely: a generic retry
decorator wrapping `validateIdToken` with `catch (\RuntimeException
$e)` will now retry on `CacheException`, `HttpException`,
`ValidationException`, `ClaimsException`, etc.

This is the intended semantic — `\RuntimeException` represents
"transient or data-shape failure," which is exactly what those
concrete types now signal. If your wrapping logic relies on the old
behaviour, narrow the catch to a more specific bundle / library
exception.

## Per-class summary

| Concrete | Parent | When it fires |
| --- | --- | --- |
| `BadUrlException` | `\LogicException` | `openIDConnectMetadataUrl` syntax invalid |
| `IllegalSchemeException` | `\LogicException` | http scheme without `allowHttp: true` |
| `MissingParameterException` | `\LogicException` | `getAuthorizationUrl()` missing required `state` / `nonce` |
| `ConfigurationException` | `\InvalidArgumentException` | Constructor missing `cacheItemPool` / `openIDConnectMetadataUrl` |
| `NegativeCacheDurationException` | `\InvalidArgumentException` | `cacheDuration` < 0 |
| `NegativeLeewayException` | `\InvalidArgumentException` | `leeway` < 0 |
| `CacheException` | `\RuntimeException` | PSR-6 cache layer failed |
| `HttpException` (+ `ClientExceptionInterface`) | `\RuntimeException` | HTTP transport failure for discovery / JWKS |
| `JsonException` | `\RuntimeException` | `json_decode` failed on an IdP response body |
| `DecodeException` | `\RuntimeException` | JWK base64 decode failed |
| `JwksException` | `\RuntimeException` | JWKS payload doesn't conform to RFC 7517 |
| `CodeException` | `\RuntimeException` | Token endpoint exchange failed |
| `ValidationException` | `\RuntimeException` | JWT signature / decode failed |
| `ClaimsException` | `\RuntimeException` | JWT claim values wrong (aud / iss / nonce) |
| `MetadataException` | `\RuntimeException` | OIDC discovery document doesn't conform to the spec |
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"ergebnis/composer-normalize": "^2.50",
"friendsofphp/php-cs-fixer": "^3.75",
"mockery/mockery": "^1.6.12",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan": "^2.1.41",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/php-code-coverage": "^12",
"phpunit/phpunit": "^12"
},
Expand Down
7 changes: 6 additions & 1 deletion phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
includes:
- vendor/phpstan/phpstan-mockery/extension.neon

parameters:
level: 8
level: max
paths:
- src
- tests
reportIgnoresWithoutComments: true
ignoreErrors:
-
identifier: missingType.iterableValue
12 changes: 11 additions & 1 deletion src/Exception/BadUrlException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

namespace ItkDev\OpenIdConnect\Exception;

class BadUrlException extends ItkOpenIdConnectException
/**
* Thrown when `openIDConnectMetadataUrl` fails URL syntax validation
* (`parse_url` rejects it because no scheme can be parsed). A programmer
* error — the value is hard-coded or comes from misread configuration, so
* fixing it requires editing code or env config, not retrying at runtime.
* Hence `\LogicException`.
*
* Distinct from {@see IllegalSchemeException} (URL parses successfully but
* uses an `http://` scheme without `allowHttp: true`).
*/
class BadUrlException extends \LogicException implements OpenIdConnectExceptionInterface
{
}
15 changes: 14 additions & 1 deletion src/Exception/CacheException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

namespace ItkDev\OpenIdConnect\Exception;

class CacheException extends ItkOpenIdConnectException
/**
* Wraps PSR-6 cache layer failures. Specifically thrown when the injected
* `Psr\Cache\CacheItemPoolInterface` raises `Psr\Cache\InvalidArgumentException`
* from `getItem` / `save` / `deleteItem` — typically because the cache key
* contains a character the backend rejects, or the backend itself is
* unhealthy. The original exception is chained via `$previous`. Hence
* `\RuntimeException` (transient — a different cache backend or a sanitized
* key may resolve it).
*
* Strictly cache-layer failures only. Discovery-document validation problems
* are {@see MetadataException}; JWKS validation problems are
* {@see JwksException}; JSON parse failures are {@see JsonException}.
*/
class CacheException extends \RuntimeException implements OpenIdConnectExceptionInterface
{
}
15 changes: 14 additions & 1 deletion src/Exception/ClaimsException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

namespace ItkDev\OpenIdConnect\Exception;

class ClaimsException extends ItkOpenIdConnectException
/**
* Thrown from `validateIdToken()` when the decoded ID token's claims
* don't match expectations — wrong `aud` (audience does not contain
* our client id), wrong `iss` (issuer doesn't match the discovery
* document), or wrong `nonce` (didn't match the value we sent on the
* authorization request). Hence `\RuntimeException` (typically requires
* either re-authenticating, or auditing why the IdP issued a token
* meant for someone else — security-relevant if persistent).
*
* Distinct from {@see ValidationException} (token cryptographically
* invalid — bad signature, expired) and from {@see CodeException}
* (failure obtaining the token in the first place, before decoding).
*/
class ClaimsException extends \RuntimeException implements OpenIdConnectExceptionInterface
{
}
Loading
Loading