diff --git a/composer.json b/composer.json index df06258..5c2d792 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "ext-filter": "*", "ext-mbstring": "*", "ext-hash": "*", + "ext-zlib": "*", "guzzlehttp/guzzle": "^7.8 || ^8.0", "psr/http-client": "^1", diff --git a/docs/1-openid.md b/docs/1-openid.md index e98acb6..e982b49 100644 --- a/docs/1-openid.md +++ b/docs/1-openid.md @@ -4,3 +4,4 @@ 2. [OpenID Federation Tools](3-federation.md) 2.1 [Federation Discovery and Entity Collection](3.1-federation-discovery.md) 3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md) +4. [Token Status List (TSL) Tools](5-token-status-list.md) diff --git a/docs/5-token-status-list.md b/docs/5-token-status-list.md new file mode 100644 index 0000000..72676b0 --- /dev/null +++ b/docs/5-token-status-list.md @@ -0,0 +1,195 @@ +# Token Status List (TSL) Tools + +Tools for the +[OAuth Status List](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-status-list) +specification: publishing the status of many issued tokens in one signed +document, and reading a single token's status back out of it. + +The specification calls the token whose status is being tracked a **Referenced +Token**. It carries a `status` claim naming a URI and an index. Fetching the +**Status List Token** from that URI and reading the bits at that index tells a +Relying Party whether the Referenced Token is still valid, has been revoked, or +is suspended. Any JOSE-based token can be a Referenced Token — a Verifiable +Credential, an SD-JWT VC, an access token. + +Only the JOSE side of the specification is implemented. CWT/COSE +representations and Status List Aggregation are not. + +To use it, create an instance of the `\SimpleSAML\OpenID\TokenStatusList` class. + +```php +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmBag; +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; +use SimpleSAML\OpenID\SupportedAlgorithms; +use SimpleSAML\OpenID\TokenStatusList; + +$tokenStatusListTools = new TokenStatusList( + new SupportedAlgorithms( + new SignatureAlgorithmBag( + SignatureAlgorithmEnum::ES256, + ), + ), +); +``` + +## Issuer side + +### Referencing a Status List from an issued token + +Allocate an index for the token you are issuing, then merge the `status` claim +into its payload. The URI and the index are yours to assign and to store; this +library does not keep track of which index belongs to which token. + +```php +$statusClaim = $tokenStatusListTools->statusReferenceFactory()->buildClaim( + 'https://example.com/statuslists/1', + 42, +); + +$payload = [...] + $statusClaim->jsonSerialize(); +// [ +// 'status' => [ +// 'status_list' => [ +// 'idx' => 42, +// 'uri' => 'https://example.com/statuslists/1', +// ], +// ], +// ] +``` + +The `uri` is used verbatim. A Relying Party rejects a Status List Token whose +`sub` claim is not equal to it, so store the exact string you issued and use it +for both the claim and the token's subject. Changing a base URL later does not +change the credentials already issued: they keep pointing at the old origin, +which has to keep serving those lists. + +### Building a Status List + +A Status List is a byte array of `capacity` entries, `bits` wide each. + +```php +use SimpleSAML\OpenID\Codebooks\StatusTypeEnum; + +$statusList = $tokenStatusListTools->statusListFactory()->fromEntries( + [ + 42 => StatusTypeEnum::Invalid, + 43 => StatusTypeEnum::Suspended, + ], + 2, // bits per entry: 1, 2, 4 or 8 + 131072, // capacity +); +``` + +`fromEntries()` keys on the index each entry carries, never on its position in +the iterable, so it can be fed straight from a database result set with gaps in +it. Every index not supplied reads as `Valid`. Use it rather than repeated +`withStatus()` calls when materialising a whole list, since `withStatus()` is +immutable and copies the byte array each time. + +Choose `bits` for the widest status the list will ever need to carry: +`StatusTypeEnum::Suspended` is `0x02` and does not fit into one bit, and a list +already in use cannot be widened. `StatusTypeEnum::requiredBits()` states the +minimum for a given status. + +Other entry points: `forCapacity()` for an all-`Valid` list, `fromEncoded()` and +`fromClaimData()` for reading one back. + +### Signing a Status List Token + +```php +use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum; + +$statusListToken = $tokenStatusListTools->statusListTokenFactory()->forStatusList( + $statusList, + 'https://example.com/statuslists/1', // becomes the `sub` claim + $signingKey, // \SimpleSAML\OpenID\Jwk\JwkDecorator + SignatureAlgorithmEnum::ES256, + new DateTimeImmutable(), // iat + (new DateTimeImmutable())->add(new DateInterval('P7D')), // exp + new DateInterval('PT12H'), // ttl + null, // optional iss + [], // additional payload claims + ['kid' => 'did:jwk:...#0'], // additional header claims +); + +$serialized = $statusListToken->getToken(); +``` + +Serve that string with a `Content-Type` of `application/statuslist+jwt`. + +The specification mandates no method for binding a Status List Token to a key, +so `kid` and `iss` are left to the caller. Whichever profile you choose, a +Relying Party has to be able to resolve the key from it — and the private key +has to be retained for as long as any list it signed is still served. + +`ttl` is how long a consumer may cache a copy, and it is therefore also how long +a revocation may go unnoticed. It is a product decision, not a technical one. + +## Relying Party side + +Validate the Referenced Token on its own terms **first**. The specification +requires that a token which fails its own validation never has its status +resolved at all, and that a token which is expired through its own claims stays +expired regardless of what the Status List says. + +```php +// Extract the reference from the validated Referenced Token's payload. +$statusReference = $tokenStatusListTools->statusReferenceFactory() + ->fromReferencedTokenPayload($referencedTokenPayload); + +if ($statusReference === null) { + // No `status` claim at all. What that means is your policy to decide. +} + +$statusResult = $tokenStatusListTools->statusResolver()->resolve( + $statusReference, + $jwks, // key set the Status List Token is verified against + 16384, // maximum bytes you are willing to decompress the list to +); + +if (!$statusResult->isValid()) { + // Revoked, suspended, or an application specific status. +} +``` + +`resolve()` fetches the Status List Token from the reference's URI (through the +cache, when one is configured), verifies its signature against the key set you +supply, checks that its subject equals that URI, decodes the list and reads the +index. Any failure throws: no statement about the Referenced Token can be made, +and it has to be rejected. + +Use `resolveWithToken()` instead when you already hold the token — an offline +flow, or a token obtained by some other means. + +`getStatusType()` returns `null` for values the specification reserves as +application specific (`0x03`, `0x0C`–`0x0F`) or has not registered; use +`getStatus()` for the raw number in that case. `isFreshAt()` answers whether the +result may still be relied upon, per the token's `ttl`. + +### Bounding decompression + +Every decoding entry point requires a maximum decompressed size from the caller. +The specification sets no maximum list size, so what counts as an acceptable +list is a deployment decision — and an unbounded `gzuncompress()` on a document +fetched from the network is a decompression bomb waiting to happen. A few +hundred bytes of input can otherwise expand to megabytes. + +Size the bound from the largest list you expect to accept: `capacity / 8` bytes +at `bits` of 1, up to one byte per entry at `bits` of 8. A compressed input bound +is derived from it, and can be given explicitly as the next argument. + +## Caching + +`StatusListTokenFetcher` caches a fetched token for the shortest of the +configured maximum cache duration, the time left until the token expires, and +the token's own `ttl`. Pass a PSR-16 cache to the `TokenStatusList` constructor +to enable it; without one, every resolution goes to the network. + +`resolve()` writes to the cache only once a token has verified and been bound to +its URI, so one bad response cannot be served back for the whole cache period. +For the same reason it treats a cached token that no longer resolves as a miss: +it discards the entry, fetches the current representation once, and caches that +instead. Otherwise a Status Provider rotating its signing key would make every +Referenced Token pointing at that URI unresolvable until the entry expired — +long after the provider itself was healthy again. A second failure is the +answer, not another fetch. diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index 69044cf..f63d73b 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -38,6 +38,9 @@ enum ClaimsEnum: string // AlternativeText case AltText = 'alt_text'; + // Status List Aggregation URI + case AggregationUri = 'aggregation_uri'; + // Authentication Methods References case Amr = 'amr'; @@ -78,6 +81,9 @@ enum ClaimsEnum: string case BatchSize = 'batch_size'; + // Number of bits per Referenced Token in a Status List + case Bits = 'bits'; + // Code hash case CHash = 'c_hash'; @@ -143,6 +149,9 @@ enum ClaimsEnum: string case Credential_Subject = 'credentialSubject'; + // Critical (JOSE header parameter, RFC 7515). + case Crit = 'crit'; + case CryptographicBindingMethodsSupported = 'cryptographic_binding_methods_supported'; case CryptographicSuitesSupported = 'cryptographic_suites_supported'; @@ -213,6 +222,9 @@ enum ClaimsEnum: string // Identifier case Id = 'id'; + // Index of a Referenced Token in a Status List + case Idx = 'idx'; + case InputMode = 'input_mode'; case IdTokenEncryptionAlgValuesSupported = 'id_token_encryption_alg_values_supported'; @@ -279,6 +291,9 @@ enum ClaimsEnum: string case LogoUri = 'logo_uri'; + // Base64url-encoded compressed byte array of a Status List + case Lst = 'lst'; + case Mandatory = 'mandatory'; case Metadata = 'metadata'; @@ -417,6 +432,9 @@ enum ClaimsEnum: string case Status = 'status'; + // Token Status List. Note: not to be confused with Credential_Status ('credentialStatus'), which is W3C VCDM. + case StatusList = 'status_list'; + // Subject case Sub = 'sub'; @@ -443,6 +461,9 @@ enum ClaimsEnum: string case TosUri = 'tos_uri'; + // Time To Live + case Ttl = 'ttl'; + // Type case Typ = 'typ'; diff --git a/src/Codebooks/ContentTypesEnum.php b/src/Codebooks/ContentTypesEnum.php index 5c119b6..555aeda 100644 --- a/src/Codebooks/ContentTypesEnum.php +++ b/src/Codebooks/ContentTypesEnum.php @@ -12,6 +12,8 @@ enum ContentTypesEnum: string case ApplicationJwt = 'application/jwt'; + case ApplicationStatusListJwt = 'application/statuslist+jwt'; + case ApplicationTrustMarkJwt = 'application/trust-mark+jwt'; case ApplicationTrustMarkStatusResponseJwt = 'application/trust-mark-status-response+jwt'; diff --git a/src/Codebooks/HttpHeadersEnum.php b/src/Codebooks/HttpHeadersEnum.php index 3c49420..7512c6e 100644 --- a/src/Codebooks/HttpHeadersEnum.php +++ b/src/Codebooks/HttpHeadersEnum.php @@ -6,6 +6,8 @@ enum HttpHeadersEnum: string { + case Accept = 'Accept'; + case ContentType = 'Content-Type'; case AccessControlAllowOrigin = 'Access-Control-Allow-Origin'; diff --git a/src/Codebooks/JwtTypesEnum.php b/src/Codebooks/JwtTypesEnum.php index 62a1886..4e16aa8 100644 --- a/src/Codebooks/JwtTypesEnum.php +++ b/src/Codebooks/JwtTypesEnum.php @@ -20,6 +20,8 @@ enum JwtTypesEnum: string case OpenId4VciProofJwt = 'openid4vci-proof+jwt'; + case StatusListJwt = 'statuslist+jwt'; + case TrustMarkJwt = 'trust-mark+jwt'; case TrustMarkDelegationJwt = 'trust-mark-delegation+jwt'; diff --git a/src/Codebooks/StatusTypeEnum.php b/src/Codebooks/StatusTypeEnum.php new file mode 100644 index 0000000..4281dc2 --- /dev/null +++ b/src/Codebooks/StatusTypeEnum.php @@ -0,0 +1,46 @@ + 1, + self::Suspended => 2, + }; + } +} diff --git a/src/Exceptions/StatusListException.php b/src/Exceptions/StatusListException.php new file mode 100644 index 0000000..5b9043c --- /dev/null +++ b/src/Exceptions/StatusListException.php @@ -0,0 +1,9 @@ +prepareErrorMessage( + 'Value is not a number, aborting.', + $value, + $context, + ); + + throw new InvalidValueException($error); + } + + /** * @return non-empty-string * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException diff --git a/src/Jws/JwsFetcher.php b/src/Jws/JwsFetcher.php index 20e9c0b..5698894 100644 --- a/src/Jws/JwsFetcher.php +++ b/src/Jws/JwsFetcher.php @@ -60,6 +60,22 @@ public function fromCacheOrNetwork(string $uri, ?float $deadlineTimestamp = null } + /** + * Whether a Content-Type response header conveys the media type this fetcher expects. + * + * Only the media type itself is compared, since parameters such as charset may legitimately follow it, and + * the comparison ignores case because RFC 9110 defines type and subtype as case-insensitive. Matching on the + * media type as a whole rather than as a substring also keeps a lookalike such as + * `application/entity-statement+jwtfoo` from passing for the type it merely starts with. + */ + protected function hasExpectedContentType(string $contentTypeHeaderLine, string $expectedContentType): bool + { + $mediaType = trim(explode(';', $contentTypeHeaderLine, 2)[0]); + + return strcasecmp($mediaType, $expectedContentType) === 0; + } + + /** * @return array */ @@ -135,7 +151,7 @@ public function fromNetwork( if ( is_string($expectedContentTypeHttpHeader = $this->getExpectedContentTypeHttpHeader()) && - (!str_contains( + (!$this->hasExpectedContentType( $response->getHeaderLine(HttpHeadersEnum::ContentType->value), $expectedContentTypeHttpHeader, )) diff --git a/src/TokenStatusList.php b/src/TokenStatusList.php new file mode 100644 index 0000000..7548b5b --- /dev/null +++ b/src/TokenStatusList.php @@ -0,0 +1,292 @@ + $httpClientConfig + */ + public function __construct( + protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(), + protected readonly SupportedSerializers $supportedSerializers = new SupportedSerializers(), + DateInterval $maxCacheDuration = new DateInterval('PT6H'), + DateInterval $timestampValidationLeeway = new DateInterval('PT1M'), + ?CacheInterface $cache = null, + protected readonly ?LoggerInterface $logger = null, + ?Client $client = null, + array $httpClientConfig = [], + int $maxFetchSizeBytes = HttpClientDecorator::DEFAULT_MAX_FETCH_SIZE_BYTES, + ) { + $this->maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration); + $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() + ->build($timestampValidationLeeway); + $this->cacheDecorator = is_null($cache) ? null : $this->cacheDecoratorFactory()->build($cache); + $this->httpClientDecorator = $this->httpClientDecoratorFactory()->build( + $client, + $httpClientConfig, + $maxFetchSizeBytes, + ); + } + + + public function dateIntervalDecoratorFactory(): DateIntervalDecoratorFactory + { + return $this->dateIntervalDecoratorFactory ??= new DateIntervalDecoratorFactory(); + } + + + public function cacheDecoratorFactory(): CacheDecoratorFactory + { + return $this->cacheDecoratorFactory ??= new CacheDecoratorFactory(); + } + + + public function httpClientDecoratorFactory(): HttpClientDecoratorFactory + { + return $this->httpClientDecoratorFactory ??= new HttpClientDecoratorFactory($this->logger); + } + + + public function maxCacheDurationDecorator(): DateIntervalDecorator + { + return $this->maxCacheDurationDecorator; + } + + + public function timestampValidationLeewayDecorator(): DateIntervalDecorator + { + return $this->timestampValidationLeewayDecorator; + } + + + public function cacheDecorator(): ?CacheDecorator + { + return $this->cacheDecorator; + } + + + public function helpers(): Helpers + { + return $this->helpers ??= new Helpers(); + } + + + public function artifactFetcher(): ArtifactFetcher + { + return $this->artifactFetcher ??= new ArtifactFetcher( + $this->httpClientDecorator, + $this->cacheDecorator(), + $this->logger, + ); + } + + + public function claimFactory(): ClaimFactory + { + return $this->claimFactory ??= new ClaimFactory( + $this->helpers(), + ); + } + + + public function jwsSerializerManagerDecoratorFactory(): JwsSerializerManagerDecoratorFactory + { + return $this->jwsSerializerManagerDecoratorFactory ??= new JwsSerializerManagerDecoratorFactory(); + } + + + public function jwsSerializerManagerDecorator(): JwsSerializerManagerDecorator + { + return $this->jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerDecoratorFactory() + ->build($this->supportedSerializers); + } + + + public function algorithmManagerDecoratorFactory(): AlgorithmManagerDecoratorFactory + { + return $this->algorithmManagerDecoratorFactory ??= new AlgorithmManagerDecoratorFactory(); + } + + + public function algorithmManagerDecorator(): AlgorithmManagerDecorator + { + return $this->algorithmManagerDecorator ??= $this->algorithmManagerDecoratorFactory() + ->build($this->supportedAlgorithms); + } + + + public function jwsDecoratorBuilderFactory(): JwsDecoratorBuilderFactory + { + return $this->jwsDecoratorBuilderFactory ??= new JwsDecoratorBuilderFactory(); + } + + + public function jwsDecoratorBuilder(): JwsDecoratorBuilder + { + return $this->jwsDecoratorBuilder ??= $this->jwsDecoratorBuilderFactory()->build( + $this->jwsSerializerManagerDecorator(), + $this->algorithmManagerDecorator(), + $this->helpers(), + ); + } + + + public function jwsVerifierDecoratorFactory(): JwsVerifierDecoratorFactory + { + return $this->jwsVerifierDecoratorFactory ??= new JwsVerifierDecoratorFactory(); + } + + + public function jwsVerifierDecorator(): JwsVerifierDecorator + { + return $this->jwsVerifierDecorator ??= $this->jwsVerifierDecoratorFactory()->build( + $this->algorithmManagerDecorator(), + ); + } + + + public function jwksDecoratorFactory(): JwksDecoratorFactory + { + return $this->jwksDecoratorFactory ??= new JwksDecoratorFactory(); + } + + + public function statusListFactory(): StatusListFactory + { + return $this->statusListFactory ??= new StatusListFactory( + $this->helpers(), + ); + } + + + public function statusReferenceFactory(): StatusReferenceFactory + { + return $this->statusReferenceFactory ??= new StatusReferenceFactory( + $this->helpers(), + ); + } + + + public function statusListTokenFactory(): StatusListTokenFactory + { + return $this->statusListTokenFactory ??= new StatusListTokenFactory( + $this->jwsDecoratorBuilder(), + $this->jwsVerifierDecorator(), + $this->jwksDecoratorFactory(), + $this->jwsSerializerManagerDecorator(), + $this->timestampValidationLeewayDecorator(), + $this->helpers(), + $this->claimFactory(), + $this->statusListFactory(), + ); + } + + + public function statusListTokenFetcher(): StatusListTokenFetcher + { + return $this->statusListTokenFetcher ??= new StatusListTokenFetcher( + $this->statusListTokenFactory(), + $this->artifactFetcher(), + $this->maxCacheDurationDecorator(), + $this->helpers(), + $this->logger, + ); + } + + + public function statusResolver(): StatusResolver + { + return $this->statusResolver ??= new StatusResolver( + $this->statusListTokenFetcher(), + $this->logger, + ); + } +} diff --git a/src/TokenStatusList/Factories/StatusListFactory.php b/src/TokenStatusList/Factories/StatusListFactory.php new file mode 100644 index 0000000..7c08070 --- /dev/null +++ b/src/TokenStatusList/Factories/StatusListFactory.php @@ -0,0 +1,391 @@ +helpers); + } + + + /** + * Builds a Status List of the given capacity with every index set to 0 (Valid). + * + * Capacity is rounded up to a whole number of bytes, so the resulting list may convey a status for a few more + * indices than asked for; the specification recommends choosing a capacity divisible by 8 anyway. + * + * @param int $capacity Number of Referenced Tokens the list is to convey a status for. + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function forCapacity(int $capacity, int $bits, ?string $aggregationUri = null): StatusList + { + if ($capacity < 1) { + throw new StatusListException( + sprintf('Status List capacity must be at least 1, %d given.', $capacity), + ); + } + + // Checked before the byte math below, which divides by it. + $this->enforceAllowedBits($bits); + + return $this->build( + $bits, + str_repeat("\x00", intdiv($capacity * $bits + 7, 8)), + $aggregationUri, + ); + } + + + /** + * Builds a Status List of the given capacity from index => status pairs, leaving every index not supplied + * at 0 (Valid). + * + * This is the bulk materialiser to use when reconstructing a list from stored entries: applying the immutable + * withStatus() per entry would copy the whole byte array each time. Entries are keyed on the index they carry, + * never on their position in the iterable, so a gap in the input leaves that index Valid rather than shifting + * every status after it. + * + * @param iterable $idxStatusPairs + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function fromEntries( + iterable $idxStatusPairs, + int $bits, + int $capacity, + ?string $aggregationUri = null, + ): StatusList { + $statusList = $this->forCapacity($capacity, $bits, $aggregationUri); + + $bytes = $statusList->getBytes(); + $listCapacity = $statusList->getCapacity(); + $entriesPerByte = intdiv(8, $bits); + $statusMask = (1 << $bits) - 1; + + foreach ($idxStatusPairs as $idx => $status) { + /** @phpstan-ignore function.alreadyNarrowedType (Keys can be strings at runtime, whatever is declared.) */ + if (!is_int($idx)) { + throw new StatusListException( + sprintf('Status List index must be an integer, %s given.', get_debug_type($idx)), + ); + } + + if ($idx < 0 || $idx >= $listCapacity) { + throw new StatusListException( + sprintf('Index %d is out of bounds of the Status List (capacity %d).', $idx, $listCapacity), + ); + } + + $value = $status instanceof StatusTypeEnum ? $status->value : $status; + + if ($value < 0 || $value > $statusMask) { + throw new StatusListException( + sprintf( + 'Status value %d at index %d can not be represented using %d bit(s) per Referenced Token ' . + '(maximum is %d).', + $value, + $idx, + $bits, + $statusMask, + ), + ); + } + + $byteIndex = intdiv($idx, $entriesPerByte); + $shift = ($idx % $entriesPerByte) * $bits; + + $bytes[$byteIndex] = chr( + (ord($bytes[$byteIndex]) & ~($statusMask << $shift)) | ($value << $shift), + ); + } + + return $this->build($bits, $bytes, $aggregationUri); + } + + + /** + * Builds a Status List from the base64url-encoded compressed byte array conveyed in the `lst` member. + * + * Decompression is bounded by the caller: the specification sets no maximum list size, so what counts as an + * acceptable list is a deployment decision, and an unbounded gzuncompress() on a token fetched from the network + * is a decompression bomb waiting to happen. + * + * @param int $maxDecompressedBytes Largest byte array the caller is willing to decompress to. With `bits` of 1 + * this is capacity/8 bytes, with `bits` of 8 it is one byte per index. + * @param ?int $maxCompressedBytes Largest compressed input the caller is willing to decode. Defaults to the + * size a normally framed stream decompressing to $maxDecompressedBytes can reach. That default is a heuristic, + * not a law: DEFLATE places no cap on how many blocks a stream may be split into, so a producer emitting a + * flush every few bytes can stay well within $maxDecompressedBytes while exceeding it. Pass an explicit value + * when interoperating with a producer whose framing is unknown. + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function fromEncoded( + string $lst, + int $bits, + int $maxDecompressedBytes, + ?int $maxCompressedBytes = null, + ?string $aggregationUri = null, + ): StatusList { + if ($maxDecompressedBytes < 1) { + throw new StatusListException( + sprintf('Maximum decompressed size must be at least 1 byte, %d given.', $maxDecompressedBytes), + ); + } + + $maxCompressedBytes ??= $this->maxCompressedBytesFor($maxDecompressedBytes); + + if ($maxCompressedBytes < 1) { + throw new StatusListException( + sprintf('Maximum compressed size must be at least 1 byte, %d given.', $maxCompressedBytes), + ); + } + + // Unpadded base64url only. Padding, the standard base64 alphabet and embedded whitespace are all rejected + // rather than normalised, so that a list only ever has the one encoding the specification defines. + // Anchored with \z rather than $, which would also match immediately before a trailing newline. + if (preg_match('/^[A-Za-z0-9_-]+\z/', $lst) !== 1) { + throw new StatusListException('Status List is not encoded as unpadded base64url.'); + } + + // Bound the input before decoding it, so an oversized list costs a strlen() rather than an allocation. + $maxEncodedLength = intdiv($maxCompressedBytes + 2, 3) * 4; + + if (strlen($lst) > $maxEncodedLength) { + throw new StatusListException( + sprintf( + 'Encoded Status List is %d characters long, which exceeds the maximum of %d.', + strlen($lst), + $maxEncodedLength, + ), + ); + } + + try { + $compressed = $this->helpers->base64Url()->decode($lst); + } catch (Throwable $throwable) { + // @codeCoverageIgnoreStart + // Not reachable while the alphabet check above stands, and kept so that a decoding failure surfaces + // as this library's own exception rather than as whatever the helper happens to throw. + throw new StatusListException( + 'Unable to base64url-decode the Status List.', + (int)$throwable->getCode(), + $throwable, + ); + // @codeCoverageIgnoreEnd + } + + // The alphabet check above does not catch a final quantum whose unused bits are not zero: base64_decode() + // ignores them, so two different strings would decode to the same bytes. Re-encoding and comparing is what + // holds the encoding to the single canonical form promised above. + if ($this->helpers->base64Url()->encode($compressed) !== $lst) { + throw new StatusListException( + 'Status List is not canonically encoded as unpadded base64url.', + ); + } + + if (strlen($compressed) > $maxCompressedBytes) { + throw new StatusListException( + sprintf( + 'Compressed Status List is %d bytes long, which exceeds the maximum of %d. An unusually ' . + 'framed but otherwise acceptable list can exceed the derived default, in which case pass an ' . + 'explicit maximum compressed size.', + strlen($compressed), + $maxCompressedBytes, + ), + ); + } + + return $this->build($bits, $this->inflate($compressed, $maxDecompressedBytes), $aggregationUri); + } + + + /** + * Decompresses the Status List, enforcing both the caller's size bound and that the input is exactly one + * complete ZLIB stream and nothing else. + * + * Done as one streaming pass rather than gzuncompress() followed by a separate check. The stream has to be + * inflated to learn where it ends, and inflating twice would hold two full copies of a list that the caller + * has already sized its bound around -- so a maximum picked to fit one decoded list could exhaust memory on + * the second copy, which is precisely the outcome the bound exists to prevent. + * + * Trailing bytes matter because a stream stops where it stops and says nothing about what follows: padding, + * or a second stream entirely, would otherwise decode to the same statuses as the clean list. That is the + * same encoding ambiguity the base64url canonicality check exists to rule out. + * + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + protected function inflate(string $compressed, int $maxDecompressedBytes): string + { + $context = inflate_init(ZLIB_ENCODING_DEFLATE); + + if ($context === false) { + // @codeCoverageIgnoreStart + throw new StatusListException('Unable to initialise decompression of the Status List.'); + // @codeCoverageIgnoreEnd + } + + $bytes = ''; + $offset = 0; + $compressedLength = strlen($compressed); + + while ($offset < $compressedLength) { + // Silenced deliberately: inflate_add() raises a warning for a malformed stream, which is reported + // through the exception below instead. + $chunk = @inflate_add( + $context, + substr($compressed, $offset, self::INFLATE_CHUNK_BYTES), + ZLIB_NO_FLUSH, + ); + + if ($chunk === false) { + throw new StatusListException('Unable to decompress the Status List.'); + } + + $bytes .= $chunk; + $offset += self::INFLATE_CHUNK_BYTES; + + if (strlen($bytes) > $maxDecompressedBytes) { + throw new StatusListException( + sprintf('Status List decompresses to more than %d bytes.', $maxDecompressedBytes), + ); + } + + if (inflate_get_status($context) === ZLIB_STREAM_END) { + break; + } + } + + // A stream that never ended is a truncated one. inflate_add() reports no error for it, since as far as it + // knows more input was simply still to come. + if (inflate_get_status($context) !== ZLIB_STREAM_END) { + throw new StatusListException('Status List does not hold a complete ZLIB stream.'); + } + + $readLength = inflate_get_read_len($context); + + if ($readLength !== $compressedLength) { + throw new StatusListException( + sprintf( + 'Compressed Status List carries %d trailing byte(s) after the end of its ZLIB stream.', + $compressedLength - $readLength, + ), + ); + } + + return $bytes; + } + + + /** + * Builds a Status List from the members of a `status_list` claim. + * + * @param array $claimData + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function fromClaimData( + array $claimData, + int $maxDecompressedBytes, + ?int $maxCompressedBytes = null, + ): StatusList { + $bits = $claimData[ClaimsEnum::Bits->value] ?? null; + + // A JSON Integer, so a numeric string is not acceptable here. + if (!is_int($bits)) { + throw new StatusListException( + sprintf('Status List bits claim must be an integer, %s given.', get_debug_type($bits)), + ); + } + + $lst = $this->helpers->type()->ensureNonEmptyString( + $claimData[ClaimsEnum::Lst->value] ?? null, + ClaimsEnum::Lst->value, + ); + + $aggregationUri = $claimData[ClaimsEnum::AggregationUri->value] ?? null; + + if ($aggregationUri !== null) { + $aggregationUri = $this->helpers->type()->ensureNonEmptyString( + $aggregationUri, + ClaimsEnum::AggregationUri->value, + ); + } + + return $this->fromEncoded($lst, $bits, $maxDecompressedBytes, $maxCompressedBytes, $aggregationUri); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + protected function enforceAllowedBits(int $bits): void + { + if (!StatusList::isAllowedBits($bits)) { + throw new StatusListException( + sprintf( + 'Invalid number of bits per Referenced Token (%d), expected one of: %s.', + $bits, + implode(', ', StatusList::ALLOWED_BITS), + ), + ); + } + } + + + /** + * Largest a ZLIB stream decompressing to the given number of bytes is expected to be. DEFLATE falls back to + * stored blocks for incompressible input, which is where a stream ends up longer than what it encodes. + * + * This is zlib's own compressBound() calculation rather than a hand-derived one, so that the bound is never + * tighter than what the compressor on the other side is entitled to emit -- including the compressor in this + * very library, whose output has to survive a round trip through fromEncoded(). + */ + protected function maxCompressedBytesFor(int $decompressedBytes): int + { + return $decompressedBytes + + ($decompressedBytes >> 12) + + ($decompressedBytes >> 14) + + ($decompressedBytes >> 25) + + self::ZLIB_BOUND_OVERHEAD; + } +} diff --git a/src/TokenStatusList/Factories/StatusListTokenFactory.php b/src/TokenStatusList/Factories/StatusListTokenFactory.php new file mode 100644 index 0000000..5177006 --- /dev/null +++ b/src/TokenStatusList/Factories/StatusListTokenFactory.php @@ -0,0 +1,170 @@ +jwsDecoratorBuilder->fromToken($token), + $this->jwsVerifierDecorator, + $this->jwksDecoratorFactory, + $this->jwsSerializerManagerDecorator, + $this->timestampValidationLeeway, + $this->helpers, + $this->claimFactory, + $this->statusListFactory, + ); + } + + + /** + * @param array $payload + * @param array $header + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function fromData( + JwkDecorator $signingKey, + SignatureAlgorithmEnum $signatureAlgorithm, + array $payload, + array $header, + ): StatusListToken { + $header[ClaimsEnum::Typ->value] = JwtTypesEnum::StatusListJwt->value; + + return new StatusListToken( + $this->jwsDecoratorBuilder->fromData( + $signingKey, + $signatureAlgorithm, + $payload, + $header, + ), + $this->jwsVerifierDecorator, + $this->jwksDecoratorFactory, + $this->jwsSerializerManagerDecorator, + $this->timestampValidationLeeway, + $this->helpers, + $this->claimFactory, + $this->statusListFactory, + ); + } + + + /** + * Builds and signs a Status List Token for the given Status List. + * + * Both the key ID and the issuer are left to the caller: the specification mandates no key resolution method, + * so which key identifier a Relying Party can resolve, and whether an `iss` claim is even meaningful, are + * properties of a deployment's chosen profile rather than of the specification. + * + * @param string $uri The URI identifying this Status List Token. It becomes the `sub` claim, and must be the + * same string the Referenced Tokens carry in their `uri` claim, byte for byte. + * @param ?\DateInterval $timeToLive Maximum time the token may be cached before a fresh copy is retrieved, + * emitted as a whole number of seconds. + * @param array $payload Any additional claims to include. + * @param array $header Any additional header claims to include, such as `kid`. + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + public function forStatusList( + StatusList $statusList, + string $uri, + JwkDecorator $signingKey, + SignatureAlgorithmEnum $signatureAlgorithm, + ?DateTimeImmutable $issuedAt = null, + ?DateTimeImmutable $expiresAt = null, + ?DateInterval $timeToLive = null, + ?string $issuer = null, + array $payload = [], + array $header = [], + ): StatusListToken { + $issuedAt ??= new DateTimeImmutable(); + + $payload[ClaimsEnum::Sub->value] = $uri; + $payload[ClaimsEnum::Iat->value] = $issuedAt->getTimestamp(); + $payload[ClaimsEnum::StatusList->value] = $statusList->jsonSerialize(); + + if ($expiresAt instanceof DateTimeImmutable) { + $payload[ClaimsEnum::Exp->value] = $expiresAt->getTimestamp(); + } + + if ($timeToLive instanceof DateInterval) { + $payload[ClaimsEnum::Ttl->value] = (new DateIntervalDecorator($timeToLive))->getInSeconds(); + } + + if ($issuer !== null) { + $payload[ClaimsEnum::Iss->value] = $issuer; + } + + return $this->fromData($signingKey, $signatureAlgorithm, $payload, $header); + } +} diff --git a/src/TokenStatusList/Factories/StatusReferenceFactory.php b/src/TokenStatusList/Factories/StatusReferenceFactory.php new file mode 100644 index 0000000..7d71216 --- /dev/null +++ b/src/TokenStatusList/Factories/StatusReferenceFactory.php @@ -0,0 +1,109 @@ +helpers); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function buildClaim(string $uri, int $idx): StatusClaim + { + return new StatusClaim($this->build($uri, $idx)); + } + + + /** + * Builds a reference from the contents of a `status_list` member. + * + * @param array $claimData + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function fromStatusListClaimData(array $claimData): StatusReference + { + $idx = $claimData[ClaimsEnum::Idx->value] ?? null; + + // A non-negative Integer, so a numeric string or a float does not satisfy it. + if (!is_int($idx)) { + throw new StatusListException( + sprintf('Status List index claim must be an integer, %s given.', get_debug_type($idx)), + ); + } + + $uri = $this->helpers->type()->ensureNonEmptyString( + $claimData[ClaimsEnum::Uri->value] ?? null, + ClaimsEnum::Uri->value, + ); + + return $this->build($uri, $idx); + } + + + /** + * Builds a reference from the payload of a Referenced Token, or returns null if the token carries no `status` + * claim at all. A `status` claim which is present but does not reference this mechanism, or which is malformed, + * is an error rather than an absence. + * + * @param array $payload + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function fromReferencedTokenPayload(array $payload): ?StatusReference + { + // Absence of the claim is the only thing that reads as "no status mechanism". A claim which is present + // but null is a malformed Referenced Token, and saying so is what stops a caller from treating it as one + // that simply carries no status. + if (!array_key_exists(ClaimsEnum::Status->value, $payload)) { + return null; + } + + $status = $payload[ClaimsEnum::Status->value]; + + if (!is_array($status)) { + throw new StatusListException( + sprintf('Status claim must be an object, %s given.', get_debug_type($status)), + ); + } + + $statusList = $status[ClaimsEnum::StatusList->value] ?? null; + + if (!is_array($statusList)) { + throw new StatusListException( + sprintf('Status List claim must be an object, %s given.', get_debug_type($statusList)), + ); + } + + return $this->fromStatusListClaimData( + $this->helpers->type()->ensureArrayWithKeysAsStrings($statusList, ClaimsEnum::StatusList->value), + ); + } +} diff --git a/src/TokenStatusList/StatusClaim.php b/src/TokenStatusList/StatusClaim.php new file mode 100644 index 0000000..50dc166 --- /dev/null +++ b/src/TokenStatusList/StatusClaim.php @@ -0,0 +1,62 @@ +statusReference; + } + + + public function getName(): string + { + return ClaimsEnum::Status->value; + } + + + /** + * @return array + */ + public function getValue(): array + { + return [ + ClaimsEnum::StatusList->value => $this->statusReference->jsonSerialize(), + ]; + } + + + /** + * The complete claim, ready to be merged into the payload of a Referenced Token. + * + * @return array + */ + public function jsonSerialize(): array + { + return [ + $this->getName() => $this->getValue(), + ]; + } +} diff --git a/src/TokenStatusList/StatusList.php b/src/TokenStatusList/StatusList.php new file mode 100644 index 0000000..c592750 --- /dev/null +++ b/src/TokenStatusList/StatusList.php @@ -0,0 +1,253 @@ +bits)) { + throw new StatusListException( + sprintf( + 'Invalid number of bits per Referenced Token (%d), expected one of: %s.', + $this->bits, + implode(', ', self::ALLOWED_BITS), + ), + ); + } + + if ($this->bytes === '') { + throw new StatusListException('Status List byte array must convey the status of at least one index.'); + } + + if ($this->aggregationUri !== null) { + $this->helpers->type()->enforceUri($this->aggregationUri, ClaimsEnum::AggregationUri->value); + } + + $this->entriesPerByte = intdiv(8, $this->bits); + $this->statusMask = (1 << $this->bits) - 1; + $this->capacity = strlen($this->bytes) * $this->entriesPerByte; + } + + + public static function isAllowedBits(int $bits): bool + { + return in_array($bits, self::ALLOWED_BITS, true); + } + + + public function getBits(): int + { + return $this->bits; + } + + + /** + * Uncompressed status byte array. + */ + public function getBytes(): string + { + return $this->bytes; + } + + + /** + * Number of indices this list conveys a status for, so one greater than the largest valid index. + */ + public function getCapacity(): int + { + return $this->capacity; + } + + + public function getAggregationUri(): ?string + { + return $this->aggregationUri; + } + + + /** + * Raw status value stored at the given index. + * + * An index outside the list is not a status of Valid: no statement about the Referenced Token can be made, + * and the specification requires the Relying Party to reject it, so this throws instead of returning a value. + * + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + public function get(int $idx): int + { + $this->enforceIndexInRange($idx); + + return (ord($this->bytes[intdiv($idx, $this->entriesPerByte)]) >> $this->shiftFor($idx)) & $this->statusMask; + } + + + /** + * Status Type stored at the given index, or null if the value is not one registered in this library (an + * application specific or a not yet registered value). Callers which treat any non-Valid value as a rejection + * should use get() and compare numerically instead. + * + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + public function getStatusType(int $idx): ?StatusTypeEnum + { + return StatusTypeEnum::tryFrom($this->get($idx)); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function withStatus(int $idx, int|StatusTypeEnum $status): self + { + $this->enforceIndexInRange($idx); + $value = $this->enforceStatusFits($status); + + $bytes = $this->bytes; + $byteIndex = intdiv($idx, $this->entriesPerByte); + $shift = $this->shiftFor($idx); + + $bytes[$byteIndex] = chr( + (ord($bytes[$byteIndex]) & ~($this->statusMask << $shift)) | ($value << $shift), + ); + + return new self($this->bits, $bytes, $this->aggregationUri, $this->helpers); + } + + + /** + * Compressed byte array in its base64url-encoded form, as conveyed in the `lst` member. + * + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + public function toEncoded(): string + { + $compressed = gzcompress($this->bytes, self::COMPRESSION_LEVEL); + + if ($compressed === false) { + // @codeCoverageIgnoreStart + // Not reachable through this class: the compression level is fixed and valid, and the byte array is + // never empty, so the only remaining cause would be an allocation failure. + throw new StatusListException('Unable to compress the Status List byte array.'); + // @codeCoverageIgnoreEnd + } + + return $this->helpers->base64Url()->encode($compressed); + } + + + /** + * @return array + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + public function jsonSerialize(): array + { + $data = [ + ClaimsEnum::Bits->value => $this->bits, + ClaimsEnum::Lst->value => $this->toEncoded(), + ]; + + if ($this->aggregationUri !== null) { + $data[ClaimsEnum::AggregationUri->value] = $this->aggregationUri; + } + + return $data; + } + + + /** + * Number of positions the status at the given index is shifted by within its byte. + */ + protected function shiftFor(int $idx): int + { + return ($idx % $this->entriesPerByte) * $this->bits; + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + protected function enforceIndexInRange(int $idx): void + { + if ($idx < 0 || $idx >= $this->capacity) { + throw new StatusListException( + sprintf('Index %d is out of bounds of the Status List (capacity %d).', $idx, $this->capacity), + ); + } + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + */ + protected function enforceStatusFits(int|StatusTypeEnum $status): int + { + $value = $status instanceof StatusTypeEnum ? $status->value : $status; + + if ($value < 0 || $value > $this->statusMask) { + throw new StatusListException( + sprintf( + 'Status value %d can not be represented using %d bit(s) per Referenced Token (maximum is %d).', + $value, + $this->bits, + $this->statusMask, + ), + ); + } + + return $value; + } +} diff --git a/src/TokenStatusList/StatusListToken.php b/src/TokenStatusList/StatusListToken.php new file mode 100644 index 0000000..2f9f92b --- /dev/null +++ b/src/TokenStatusList/StatusListToken.php @@ -0,0 +1,451 @@ +value) { + throw new StatusListTokenException( + sprintf( + 'Invalid Type header claim (%s), expected %s.', + $typ, + JwtTypesEnum::StatusListJwt->value, + ), + ); + } + + return $typ; + } + + + /** + * @return non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getAlgorithm(): string + { + return parent::getAlgorithm() ?? throw new StatusListTokenException('No Algorithm header claim found.'); + } + + + /** + * Rejects a token whose protected header marks any parameter as critical. + * + * RFC 7515 gives `crit` the meaning "understand every parameter listed here or reject this token", and only + * lets it name extensions -- parameters the JOSE specifications themselves do not define. This token type + * defines no extensions and this library implements none, so whatever is listed there is by construction + * something the code below does not understand. Verifying the signature and reading the list out anyway + * would be skipping semantics the producer said were mandatory, which is how a status ends up read under + * rules that were never applied. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function enforceNoCriticalHeaderParameters(): void + { + $claimKey = ClaimsEnum::Crit->value; + + // Presence, not value: RFC 7515 requires a `crit` that is there to be a non-empty array of strings, so + // `"crit": null` is a malformed declaration rather than an omitted one, and it is not going to be waved + // through by a method whose whole purpose is to reject declarations it cannot honour. + if (!array_key_exists($claimKey, $this->getHeader())) { + return; + } + + $crit = $this->getHeaderClaim($claimKey); + + throw new StatusListTokenException( + sprintf( + 'Token marks header parameters as critical, and none are supported: %s.', + implode( + ', ', + array_map( + static fn(mixed $value): string => var_export($value, true), + is_array($crit) ? $crit : [$crit], + ), + ), + ), + ); + } + + + /** + * The URI of this Status List Token. A Relying Party rejects the token unless this is equal to the `uri` its + * Referenced Token pointed at. + * + * @return non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getSubject(): string + { + $sub = parent::getSubject() ?? throw new StatusListTokenException('No Subject claim found.'); + + return $this->helpers->type()->enforceUri($sub, ClaimsEnum::Sub->value); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getIssuedAt(): int + { + $claimKey = ClaimsEnum::Iat->value; + + $iat = $this->getPayloadClaim($claimKey) ?? throw new StatusListTokenException('No Issued At claim found.'); + + $this->enforceNumericDate($iat, $claimKey); + + return parent::getIssuedAt() ?? throw new StatusListTokenException('No Issued At claim found.'); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getExpirationTime(): ?int + { + $claimKey = ClaimsEnum::Exp->value; + + // Optional, but only by being absent. Present and null is a malformed claim, not an omitted one. + if ($this->hasPayloadClaim($claimKey)) { + $this->enforceNumericDate($this->getPayloadClaim($claimKey), $claimKey); + } + + return parent::getExpirationTime(); + } + + + /** + * The Not Before claim is not one the specification asks a Status List Token to carry, but a token may carry + * it, and the inherited validation acts on it when it is there. It therefore gets the same NumericDate + * treatment as the claims that are specified, rather than the laxer inherited handling. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getNotBefore(): ?int + { + $claimKey = ClaimsEnum::Nbf->value; + + if ($this->hasPayloadClaim($claimKey)) { + $this->enforceNumericDate($this->getPayloadClaim($claimKey), $claimKey); + } + + return parent::getNotBefore(); + } + + + /** + * Enforces that the claims which are optional are optional by being absent, rather than by being present + * with a null value. + * + * The inherited getters read a claim with `??` and return null for one that is missing, which makes an + * explicit null indistinguishable from an omission and lets it pass for a claim that was never there. That + * is the same rule already applied to `exp`, `nbf`, `ttl` and `crit`, applied here to the registered claims + * this token type does not itself require but which have a defined shape once present. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function enforceNoNullOptionalClaims(): void + { + $payload = $this->getPayload(); + $header = $this->getHeader(); + + /** @var array> $optionalClaims */ + $optionalClaims = [ + ClaimsEnum::Iss->value => $payload, + ClaimsEnum::Aud->value => $payload, + ClaimsEnum::Jti->value => $payload, + ClaimsEnum::Kid->value => $header, + ]; + + foreach ($optionalClaims as $claimKey => $claims) { + if (array_key_exists($claimKey, $claims) && is_null($claims[$claimKey])) { + throw new StatusListTokenException( + sprintf('Claim %s is present and null, which is not a value it may take.', $claimKey), + ); + } + } + } + + + /** + * Whether the payload carries the claim at all, as distinct from carrying it with a null value. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function hasPayloadClaim(string $key): bool + { + return array_key_exists($key, $this->getPayload()); + } + + + /** + * Enforces that a claim is an RFC 7519 NumericDate, being a JSON number rather than a numeric string, and one + * whose whole-second part survives the conversion to an integer that the inherited getters perform. + * + * The range check is what stops a nonsensical value from becoming a plausible one: casting a float larger than + * the integer range yields 0 (or a negative number) rather than saturating, so an `iat` of 1e100 would + * otherwise read as the epoch and sail past the check that a token was not issued in the future. + * + * RFC 7519 permits a NumericDate to carry a fraction of a second, so one is accepted here rather than + * rejected -- but the inherited getters return whole seconds and compare against a whole-second clock, so a + * fractional claim is truncated towards zero on the way out. The subsecond part is therefore not what any + * decision rests on: it is bounded by definition at under a second, against a timestamp validation leeway + * that is a minute by default and exists to absorb clock skew orders of magnitude larger. `exp` truncating + * downwards expires a token marginally sooner rather than later. Callers needing the claim exactly as signed + * should read it through getPayloadClaim(), the narrower return type here not being one a subclass may widen. + * + * @throws \SimpleSAML\OpenID\Exceptions\StatusListTokenException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + protected function enforceNumericDate(mixed $value, string $claimKey): void + { + $value = $this->helpers->type()->ensureNumber($value, $claimKey); + + if ($value < -self::MAX_NUMERIC_DATE || $value > self::MAX_NUMERIC_DATE) { + throw new StatusListTokenException( + sprintf( + 'Claim %s is not a usable NumericDate: %s is outside the representable range.', + $claimKey, + var_export($value, true), + ), + ); + } + } + + + /** + * Maximum amount of time, in seconds, that this token can be cached before a fresh copy should be retrieved. + * + * The specification requires a positive number encoded in JSON as a number, and does not require it to be a + * whole one, so a fractional value is returned as given rather than rounded into an integer. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getTimeToLive(): int|float|null + { + $claimKey = ClaimsEnum::Ttl->value; + + // Optional, but only by being absent. Present and null is a malformed claim, not an omitted one. + if (!$this->hasPayloadClaim($claimKey)) { + return null; + } + + $ttl = $this->helpers->type()->ensureNumber($this->getPayloadClaim($claimKey), $claimKey); + + if ($ttl <= 0) { + throw new StatusListTokenException( + sprintf('Time To Live claim must be a positive number, %s given.', var_export($ttl, true)), + ); + } + + // Bounded for the same reason a NumericDate is: consumers turn this into an integer number of seconds, + // and a value past the integer range casts to zero rather than saturating, which would read as "do not + // cache at all" and turn a nonsensical claim into a stream of requests to the Status Provider. + if ($ttl > self::MAX_NUMERIC_DATE) { + throw new StatusListTokenException( + sprintf( + 'Time To Live claim is outside the representable range: %s given.', + var_export($ttl, true), + ), + ); + } + + return $ttl; + } + + + /** + * Members of the `status_list` claim, before the list itself is decoded. + * + * @return array + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getStatusListClaimData(): array + { + $claimKey = ClaimsEnum::StatusList->value; + + $statusList = $this->getPayloadClaim($claimKey) ?? throw new StatusListTokenException( + 'No Status List claim found.', + ); + + if (!is_array($statusList)) { + throw new StatusListTokenException( + sprintf('Status List claim must be an object, %s given.', get_debug_type($statusList)), + ); + } + + return $this->helpers->type()->ensureArrayWithKeysAsStrings($statusList, $claimKey); + } + + + /** + * Number of bits per Referenced Token the embedded Status List uses. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getBits(): int + { + $claimKey = ClaimsEnum::Bits->value; + + $bits = $this->getStatusListClaimData()[$claimKey] ?? null; + + // A JSON Integer, so a numeric string does not satisfy it. + if (!is_int($bits)) { + throw new StatusListTokenException( + sprintf('Status List bits claim must be an integer, %s given.', get_debug_type($bits)), + ); + } + + if (!StatusList::isAllowedBits($bits)) { + throw new StatusListTokenException( + sprintf( + 'Invalid number of bits per Referenced Token (%d), expected one of: %s.', + $bits, + implode(', ', StatusList::ALLOWED_BITS), + ), + ); + } + + return $bits; + } + + + /** + * The embedded Status List in its encoded form, before it is decoded and bounded. + * + * @return non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getEncodedStatusList(): string + { + $claimKey = ClaimsEnum::Lst->value; + + return $this->helpers->type()->ensureNonEmptyString( + $this->getStatusListClaimData()[$claimKey] ?? null, + $claimKey, + ); + } + + + /** + * Decodes the embedded Status List. + * + * Decompression is bounded by the caller, since the specification sets no maximum list size; see + * StatusListFactory::fromEncoded(). + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getStatusList(int $maxDecompressedBytes, ?int $maxCompressedBytes = null): StatusList + { + return $this->statusListFactory->fromClaimData( + $this->getStatusListClaimData(), + $maxDecompressedBytes, + $maxCompressedBytes, + ); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function validate(): void + { + $this->validateByCallbacks( + $this->getAlgorithm(...), + $this->getType(...), + $this->enforceNoCriticalHeaderParameters(...), + $this->getSubject(...), + $this->getIssuedAt(...), + $this->getExpirationTime(...), + $this->getTimeToLive(...), + $this->getStatusListClaimData(...), + // Structure only. Decoding the list needs a size bound the caller has to supply, so it stays explicit. + $this->getBits(...), + $this->getEncodedStatusList(...), + // Claims the specification does not ask for, but which a token may carry and which have a defined + // shape once present. A Status List Token that is invalid as a plain JWT is not one to work from, + // and leaving these unchecked lets an instance exist whose own getters throw when anything reads it. + $this->getIssuer(...), + $this->getAudience(...), + $this->getJwtId(...), + $this->getKeyId(...), + $this->enforceNoNullOptionalClaims(...), + ); + } +} diff --git a/src/TokenStatusList/StatusListTokenFetcher.php b/src/TokenStatusList/StatusListTokenFetcher.php new file mode 100644 index 0000000..5a15b7d --- /dev/null +++ b/src/TokenStatusList/StatusListTokenFetcher.php @@ -0,0 +1,231 @@ +parsedJwsFactory->fromToken($token); + } + + + public function getExpectedContentTypeHttpHeader(): string + { + return ContentTypesEnum::ApplicationStatusListJwt->value; + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + */ + public function fromCacheOrNetwork(string $uri, ?float $deadlineTimestamp = null): StatusListToken + { + return $this->fromCache($uri) ?? $this->fromNetwork( + $uri, + HttpMethodsEnum::GET, + $this->timeoutCeilingOptions($deadlineTimestamp), + ); + } + + + /** + * Fetch a Status List Token from the network without caching it. + * + * This is what a caller wanting to cache only what it has verified reaches for: nothing about a token is + * trustworthy until its signature and subject have been checked, and this fetcher holds no key material to + * check them with. StatusResolver fetches this way and calls cacheIt() once verification has succeeded. + * + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function fromNetworkWithoutCaching(string $uri, ?float $deadlineTimestamp = null): StatusListToken + { + return $this->fromNetwork( + $uri, + HttpMethodsEnum::GET, + $this->timeoutCeilingOptions($deadlineTimestamp), + false, + ); + } + + + /** + * Cache a Status List Token that the caller has established it can trust, for the shortest of the configured + * maximum cache duration, the time left until the token expires, and the token's own `ttl`. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function cacheIt(StatusListToken $statusListToken, string $uri, string ...$additionalCacheKeyElements): void + { + $cacheTtl = $this->resolveCacheTtl($statusListToken); + + if ($cacheTtl < 1) { + return; + } + + $this->artifactFetcher->cacheIt( + $statusListToken->getToken(), + $cacheTtl, + $uri, + ...$additionalCacheKeyElements, + ); + } + + + /** + * Adds the Accept header stating that this fetcher wants the JWT representation. + * + * The specification defines both a JWT and a CWT representation of a Status List Token and expects them to be + * chosen between by HTTP content negotiation. This fetcher only understands the JWT one, so asking for it is + * what keeps a provider that would otherwise default to CWT interoperable, rather than having its perfectly + * valid response rejected on arrival. + * + * @param array $options + * @return array + */ + protected function acceptJwtOptions(array $options): array + { + $headers = (isset($options['headers']) && is_array($options['headers'])) ? $options['headers'] : []; + + // HTTP header names are case-insensitive, so a caller's `accept` is the same header as our `Accept`. + // Adding a second entry would have the client send both, combining them into a negotiation the caller + // never asked for. + foreach (array_keys($headers) as $name) { + if (is_string($name) && strcasecmp($name, HttpHeadersEnum::Accept->value) === 0) { + return $options; + } + } + + $headers[HttpHeadersEnum::Accept->value] = ContentTypesEnum::ApplicationStatusListJwt->value; + + $options['headers'] = $headers; + + return $options; + } + + + /** + * Fetch Status List Token from cache, if available. URI is used as cache key. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + */ + public function fromCache(string $uri): ?StatusListToken + { + $statusListToken = parent::fromCache($uri); + + if (is_null($statusListToken)) { + return null; + } + + if ($statusListToken instanceof StatusListToken) { + return $statusListToken; + } + + // @codeCoverageIgnoreStart + $message = 'Unexpected Status List Token instance encountered for cache fetch.'; + $this->logger?->error($message, ['uri' => $uri]); + + throw new FetchException($message); + // @codeCoverageIgnoreEnd + } + + + /** + * Fetch Status List Token from network. + * + * Caching honours the `ttl` claim in addition to the expiration time, since `ttl` is precisely the issuer + * saying how long a consumer may hold on to a copy, and a token may well carry a long expiration time + * together with a short `ttl`. Holding a token past its `ttl` is how a revocation goes unnoticed for longer + * than the issuer intended. + * + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html + * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. + * @param string ...$additionalCacheKeyElements Additional string elements to be used as cache key. + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + string ...$additionalCacheKeyElements, + ): StatusListToken { + // Caching is taken over below, so that the ttl claim can be taken into account. + $statusListToken = parent::fromNetwork($uri, $httpMethodsEnum, $this->acceptJwtOptions($options), false); + + if (!$statusListToken instanceof StatusListToken) { + // @codeCoverageIgnoreStart + $message = 'Unexpected Status List Token instance encountered for network fetch.'; + $this->logger?->error($message, ['uri' => $uri]); + + throw new FetchException($message); + // @codeCoverageIgnoreEnd + } + + if ($shouldCache) { + $this->cacheIt($statusListToken, $uri, ...$additionalCacheKeyElements); + } + + return $statusListToken; + } + + + /** + * Shortest of the configured maximum cache duration, the time left until the token expires, and the token's + * own `ttl`. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + protected function resolveCacheTtl(StatusListToken $statusListToken): int + { + $cacheTtl = is_int($expirationTime = $statusListToken->getExpirationTime()) ? + $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime($expirationTime) : + $this->maxCacheDuration->getInSeconds(); + + $ttl = $statusListToken->getTimeToLive(); + + if ($ttl !== null) { + return min($cacheTtl, (int)$ttl); + } + + return $cacheTtl; + } +} diff --git a/src/TokenStatusList/StatusReference.php b/src/TokenStatusList/StatusReference.php new file mode 100644 index 0000000..0d45465 --- /dev/null +++ b/src/TokenStatusList/StatusReference.php @@ -0,0 +1,68 @@ +helpers->type()->enforceUri($this->uri, ClaimsEnum::Uri->value); + + if ($this->idx < 0) { + throw new StatusListException( + sprintf('Status List index must be a non-negative integer, %d given.', $this->idx), + ); + } + } + + + public function getUri(): string + { + return $this->uri; + } + + + public function getIdx(): int + { + return $this->idx; + } + + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + ClaimsEnum::Idx->value => $this->idx, + ClaimsEnum::Uri->value => $this->uri, + ]; + } +} diff --git a/src/TokenStatusList/StatusResolver.php b/src/TokenStatusList/StatusResolver.php new file mode 100644 index 0000000..7290e71 --- /dev/null +++ b/src/TokenStatusList/StatusResolver.php @@ -0,0 +1,174 @@ +logger?->debug( + 'Resolving Referenced Token status.', + ['uri' => $statusReference->getUri(), 'idx' => $statusReference->getIdx()], + ); + + try { + $cachedStatusListToken = $this->statusListTokenFetcher->fromCache($statusReference->getUri()); + + if ($cachedStatusListToken instanceof StatusListToken) { + return $this->resolveWithToken( + $statusReference, + $cachedStatusListToken, + $jwks, + $maxDecompressedBytes, + $maxCompressedBytes, + ); + } + } catch (OpenIdException $openIdException) { + // A cached entry that cannot be read, or that no longer resolves, is worth no more than an empty + // cache, so it is treated as a miss and the current representation is fetched once. Failing outright + // instead would let a key rotation at the Status Provider make every Referenced Token pointing at + // this URI unresolvable until the entry expired, which is up to the configured maximum cache + // duration after the provider itself was healthy again. The read is inside the attempt for the same + // reason: whatever is in the cache under this URI failing to parse is no more of an answer about the + // Referenced Token than a stale token is. + $this->logger?->warning( + 'Cached Status List Token was unusable, discarding it and fetching a current one.', + ['uri' => $statusReference->getUri(), 'error' => $openIdException->getMessage()], + ); + } + + $statusListToken = $this->statusListTokenFetcher->fromNetworkWithoutCaching( + $statusReference->getUri(), + $deadlineTimestamp, + ); + + $statusResult = $this->resolveWithToken( + $statusReference, + $statusListToken, + $jwks, + $maxDecompressedBytes, + $maxCompressedBytes, + ); + + // Cached only now, once the token has been verified and bound to the URI it was fetched from. Caching on + // arrival would let one bad response from a Status Provider keep being served back for the whole cache + // period, rejecting every Referenced Token that points at it long after the provider itself recovered. + // This also replaces whatever unusable entry the fetch above was reached for. + $this->statusListTokenFetcher->cacheIt($statusListToken, $statusReference->getUri()); + + return $statusResult; + } + + + /** + * Resolves the status of a Referenced Token against a Status List Token the caller already holds, which is + * what an offline flow or a locally cached token needs. + * + * @param mixed[] $jwks Key set the Status List Token is verified against. + * @param ?\DateTimeImmutable $resolvedAt Time the Status List Token was obtained, defaulting to now. It is + * what StatusResult::isFreshAt() measures the `ttl` window from, so a caller resolving against a token it has + * been holding should pass the time it actually obtained that token rather than leaving this to default; + * otherwise the window restarts and the result can be believed fresh for longer than the issuer allowed. + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\StatusListException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function resolveWithToken( + StatusReference $statusReference, + StatusListToken $statusListToken, + array $jwks, + int $maxDecompressedBytes, + ?int $maxCompressedBytes = null, + ?DateTimeImmutable $resolvedAt = null, + ): StatusResult { + // The signature comes before anything is read out of the token. + $statusListToken->verifyWithKeySet($jwks); + + // Expiration is checked again here, not only when the token was parsed. A token handed to this method may + // have been parsed long ago -- an offline flow, or one held across requests -- and an expired Status List + // must not be what decides a Referenced Token's status. The getter compares against the time of the call, + // so this throws for a token that has expired since. + $statusListToken->getExpirationTime(); + + $subject = $statusListToken->getSubject(); + + // The subject must be the very URI the Referenced Token pointed at. Compared as given, since a token + // served from a URI other than the one the credential names is exactly what this check is for. + if ($subject !== $statusReference->getUri()) { + $message = 'Status List Token subject does not match the URI of the Referenced Token.'; + $this->logger?->error( + $message, + ['subject' => $subject, 'uri' => $statusReference->getUri()], + ); + + throw new StatusListTokenException( + sprintf( + '%s Subject was %s, expected %s.', + $message, + var_export($subject, true), + var_export($statusReference->getUri(), true), + ), + ); + } + + $status = $statusListToken->getStatusList($maxDecompressedBytes, $maxCompressedBytes) + ->get($statusReference->getIdx()); + + $this->logger?->debug( + 'Resolved Referenced Token status.', + ['uri' => $statusReference->getUri(), 'idx' => $statusReference->getIdx(), 'status' => $status], + ); + + return new StatusResult( + $status, + $statusReference, + $statusListToken, + $resolvedAt ?? new DateTimeImmutable(), + ); + } +} diff --git a/src/TokenStatusList/StatusResult.php b/src/TokenStatusList/StatusResult.php new file mode 100644 index 0000000..750c72e --- /dev/null +++ b/src/TokenStatusList/StatusResult.php @@ -0,0 +1,132 @@ +status; + } + + + /** + * Status Type for the resolved value, or null if the value is application specific or not yet registered. + */ + public function getStatusType(): ?StatusTypeEnum + { + return StatusTypeEnum::tryFrom($this->status); + } + + + /** + * Whether the Referenced Token is valid according to the Status List alone. + * + * This is not the whole answer about the Referenced Token: the processing rules for the token itself + * supersede its status here, so a token which is expired through its own claims stays expired even when this + * returns true. + */ + public function isValid(): bool + { + return $this->status === StatusTypeEnum::Valid->value; + } + + + public function getStatusReference(): StatusReference + { + return $this->statusReference; + } + + + public function getStatusListToken(): StatusListToken + { + return $this->statusListToken; + } + + + public function getResolvedAt(): DateTimeImmutable + { + return $this->resolvedAt; + } + + + /** + * Whether this result may still be relied upon at the given time. + * + * Two limits apply, whichever falls first: the `ttl` claim, being how long the issuer permits a copy to be + * held from the time the status was resolved, and the `exp` claim, past which the Status List Token states + * nothing at all. A token carrying neither states no limit, so the caller's own policy is all there is and + * this returns true. + * + * Note what the `ttl` limit is measured from. When a result came from a Status List Token that the fetcher + * served out of its cache, the `ttl` window is measured from that resolution, not from when the token was + * originally retrieved, so a result held across a cache hit can be considered fresh for up to two `ttl` + * periods after the token reached this process. `exp` bounds that, and so does the fetcher's own cache + * lifetime, which never exceeds `ttl`. Callers wanting the tighter guarantee should resolve again rather + * than hold a result, or pass the original retrieval time to StatusResolver::resolveWithToken(). + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function isFreshAt(DateTimeImmutable $moment): bool + { + // Compared with the subsecond part intact. Both limits are specified as numbers rather than whole + // seconds -- `ttl` explicitly so, and a NumericDate by RFC 7519 -- and truncating either side to whole + // seconds can leave a result looking fresh for most of a second after its window closed. + $timestamp = $this->toFloatTimestamp($moment); + + $ttl = $this->statusListToken->getTimeToLive(); + + // `ttl` is how long a copy may be held from the time it was obtained, so the instant it runs out is the + // last one still inside the window the issuer allowed. + if ($ttl !== null && $this->toFloatTimestamp($this->resolvedAt) + $ttl < $timestamp) { + return false; + } + + // Read from the payload rather than through getExpirationTime(), which throws once the token is expired + // and would turn the answer to a question about freshness into an exception. + $exp = $this->statusListToken->getPayloadClaim(ClaimsEnum::Exp->value); + + // RFC 7519 asks for the current time to be *before* `exp`, so unlike the `ttl` window the limit itself + // is already outside it. The claim was bounded to a usable NumericDate when the token was validated, so + // the cast here cannot turn a nonsensical value into a plausible one. + if (is_int($exp) || is_float($exp)) { + return (float)$exp > $timestamp; + } + + return true; + } + + + /** + * Seconds since the epoch, with the subsecond part kept. + */ + protected function toFloatTimestamp(DateTimeImmutable $moment): float + { + return (float)$moment->format('U.u'); + } +} diff --git a/tests/data/status-list-test-vectors-appendix-c.json b/tests/data/status-list-test-vectors-appendix-c.json new file mode 100644 index 0000000..e929ce4 --- /dev/null +++ b/tests/data/status-list-test-vectors-appendix-c.json @@ -0,0 +1,327 @@ +{ + "C.1": { + "bits": 1, + "capacity": 1048576, + "byteLength": 131072, + "statuses": { + "0": 1, + "1993": 1, + "25460": 1, + "159495": 1, + "495669": 1, + "554353": 1, + "645645": 1, + "723232": 1, + "854545": 1, + "934534": 1, + "1000345": 1 + }, + "lst": "eNrt3AENwCAMAEGogklACtKQPg9LugC9k_ACvreiogEAAKkeCQAAAAAAAAAAAAAAAAAAAIBylgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAXG9IAAAAAAAAAPwsJAAAAAAAAAAAAAAAvhsSAAAAAAAAAAAA7KpLAAAAAAAAAAAAAAAAAAAAAJsLCQAAAAAAAAAAADjelAAAAAAAAAAAKjDMAQAAAACAZC8L2AEb" + }, + "C.2": { + "bits": 2, + "capacity": 1048576, + "byteLength": 262144, + "statuses": { + "0": 1, + "1993": 2, + "25460": 1, + "159495": 3, + "495669": 1, + "554353": 1, + "645645": 2, + "723232": 1, + "854545": 1, + "934534": 2, + "1000345": 3 + }, + "lst": "eNrt2zENACEQAEEuoaBABP5VIO01fCjIHTMStt9ovGVIAAAAAABAbiEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEB5WwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID0ugQAAAAAAAAAAAAAAAAAQG12SgAAAAAAAAAAAAAAAAAAAAAAAAAAAOCSIQEAAAAAAAAAAAAAAAAAAAAAAAD8ExIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwJEuAQAAAAAAAAAAAAAAAAAAAAAAAMB9SwIAAAAAAAAAAAAAAAAAAACoYUoAAAAAAAAAAAAAAEBqH81gAQw" + }, + "C.3": { + "bits": 4, + "capacity": 1048576, + "byteLength": 524288, + "statuses": { + "0": 1, + "1993": 2, + "35460": 3, + "459495": 4, + "595669": 5, + "754353": 6, + "845645": 7, + "923232": 8, + "924445": 9, + "934534": 10, + "1000345": 12, + "1004534": 11, + "1030203": 13, + "1030204": 14, + "1030205": 15 + }, + "lst": "eNrt0EENgDAQADAIHwImkIIEJEwCUpCEBBQRHOy35Li1EjoOQGabAgAAAAAAAAAAAAAAAAAAACC1SQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABADrsCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADoxaEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIoCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACArpwKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhqVkAzlwIAAAAAiGVRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABx3AoAgLpVAQAAAAAAAAAAAAAAwM89rwMAAAAAAAAAAAjsA9xMBMA" + }, + "C.4": { + "bits": 8, + "capacity": 1048576, + "byteLength": 1048576, + "statuses": { + "1199": 121, + "4520": 213, + "6805": 112, + "13628": 20, + "15080": 156, + "19535": 255, + "24077": 103, + "30949": 201, + "40845": 124, + "44112": 153, + "52451": 1, + "55649": 14, + "62489": 10, + "65877": 161, + "66653": 9, + "67319": 70, + "83344": 240, + "91122": 35, + "96100": 44, + "98810": 93, + "105156": 228, + "106091": 200, + "113605": 181, + "117310": 68, + "119285": 231, + "119884": 227, + "121477": 129, + "122648": 224, + "122967": 210, + "123467": 158, + "127113": 183, + "127837": 225, + "133227": 98, + "134272": 151, + "138807": 74, + "141348": 160, + "142524": 235, + "143699": 234, + "145149": 134, + "145867": 43, + "148598": 163, + "148883": 157, + "149741": 59, + "152133": 254, + "158093": 197, + "165165": 131, + "165255": 39, + "167335": 47, + "168005": 232, + "171931": 241, + "179628": 132, + "182323": 7, + "190650": 92, + "194502": 29, + "195586": 202, + "196493": 11, + "197815": 174, + "202828": 31, + "203278": 175, + "205329": 199, + "206397": 182, + "211815": 49, + "214350": 28, + "214947": 6, + "223159": 115, + "229153": 186, + "229950": 94, + "233478": 0, + "233941": 107, + "234603": 125, + "240232": 104, + "251420": 238, + "253764": 148, + "261779": 37, + "263738": 135, + "269256": 136, + "274471": 250, + "277478": 168, + "290102": 90, + "292632": 5, + "292826": 83, + "293983": 191, + "299623": 81, + "320527": 118, + "320531": 95, + "324632": 220, + "326064": 25, + "330160": 230, + "331680": 110, + "334829": 21, + "335506": 96, + "338792": 120, + "341110": 54, + "342679": 62, + "345269": 211, + "346296": 138, + "348779": 204, + "358903": 60, + "374660": 114, + "384802": 193, + "389703": 233, + "398244": 221, + "398896": 189, + "399318": 173, + "401793": 188, + "403258": 42, + "416992": 15, + "417146": 117, + "421844": 142, + "428362": 245, + "439368": 57, + "442463": 75, + "444173": 141, + "445910": 77, + "448260": 208, + "449287": 167, + "451545": 26, + "456551": 223, + "458517": 12, + "459948": 89, + "460566": 66, + "461829": 155, + "462297": 17, + "468106": 4, + "471224": 150, + "477937": 45, + "480125": 159, + "480348": 69, + "482585": 154, + "487925": 13, + "488197": 48, + "493258": 236, + "495723": 203, + "499131": 164, + "500560": 247, + "502167": 128, + "505144": 130, + "509602": 170, + "513405": 61, + "513575": 3, + "516351": 239, + "517215": 84, + "519895": 149, + "521470": 187, + "523882": 196, + "527253": 102, + "546865": 244, + "551009": 85, + "552366": 113, + "554740": 34, + "555493": 82, + "555864": 139, + "556171": 215, + "557034": 248, + "559572": 105, + "559893": 58, + "561493": 73, + "566121": 65, + "576778": 2, + "582738": 24, + "582952": 51, + "583408": 19, + "584009": 165, + "584151": 194, + "598210": 217, + "606890": 46, + "614839": 40, + "615514": 108, + "622241": 222, + "625884": 116, + "629139": 251, + "644903": 127, + "653716": 143, + "657676": 226, + "658891": 246, + "659927": 76, + "661552": 71, + "663071": 253, + "663475": 243, + "679804": 122, + "680070": 67, + "692958": 162, + "705889": 27, + "709016": 152, + "711569": 185, + "713399": 106, + "713557": 23, + "721327": 33, + "743015": 177, + "745056": 56, + "752834": 32, + "758403": 41, + "759161": 88, + "761225": 126, + "765108": 53, + "776325": 55, + "783119": 145, + "784154": 119, + "793844": 38, + "794764": 212, + "795775": 64, + "796765": 30, + "797182": 50, + "800313": 100, + "806915": 99, + "818773": 214, + "829700": 79, + "830023": 249, + "836747": 144, + "837603": 87, + "841042": 209, + "841303": 72, + "844358": 184, + "846778": 237, + "852312": 205, + "853666": 172, + "862143": 179, + "879178": 242, + "879796": 16, + "884749": 192, + "884834": 8, + "885333": 97, + "886286": 22, + "887110": 218, + "887384": 140, + "888308": 178, + "898490": 86, + "903979": 176, + "911768": 109, + "918762": 146, + "929312": 198, + "940810": 190, + "942059": 18, + "946835": 147, + "950870": 52, + "951527": 111, + "954221": 216, + "958869": 252, + "962282": 80, + "963483": 36, + "969429": 63, + "970201": 195, + "979421": 180, + "981571": 101, + "991262": 169, + "991896": 171, + "996739": 137, + "999897": 229, + "1009481": 207, + "1017987": 166, + "1018463": 206, + "1019195": 133, + "1020623": 219, + "1024680": 123, + "1034977": 91, + "1046963": 78 + }, + "lst": "eNrt0WOQM2kYhtGsbdu2bdu2bdu2bdu2bdu2jVnU1my-SWYm6U5enFPVf7ue97orFYAo7CQBAACQuuckAABStqUEAAAAAAAAtN6wEgAE71QJAAAAAIrwhwQAAAAAAdtAAgAAAAAAACLwkAQAAAAAAAAAAACUaFcJAACAeJwkAQAAAAAAAABQvL4kAAAAWmJwCQAAAAAAAAjAwBIAAAB06ywJoDKQBARpfgkAAAAAAAAAAAAAAAAAAACo50sJAAAAAAAAAOiRcSQAAAAAgAJNKgEAAG23mgQAAAAAAECw3pUAQvegBAAAAAAAAADduE4CAAAAyjSvBAAQiw8koHjvSABAb-wlARCONyVoxtMSZOd0CQAAAOjWDRKQmLckAAAAAACysLYEQGcnSAAAAAAQooUlAABI15kSAIH5RAIgLB9LABC4_SUgGZNIAABAmM6RoLbTJIASzCIBAEAhfpcAAAAAAABquk8CAAAAAAAAaJl9SvvzBOICAFWmkIBgfSgBAAAANOgrCQAAAAAAAADStK8EAAC03gASAAAAAAAAAADFWFUCAAAAMjOaBEADHpYAQjCIBADduFwCAAAAAGitMSSI3BUSAECOHpAA6IHrJQAAAAAAsjeVBAAAKRpVAorWvwQAAAAAAAAAkKRtJAAAAAAAgCbcLAF0bXUJAAAAoF02kYDg7CYBAAAAAEB6NpQAAAAAAAAAAAAAAEr1uQQAAF06VgIAAAAAAAAAqDaeBAAQqgMkAAAAAABogQMlAAAAAAAa87MEAAAQiwslAAAAAAAAAAAAAAAAMrOyBAAAiekv-hcsY0Sgne6QAAAAAAAgaUtJAAAAAAAAAAAAAAAAAAAAAAAAAADwt-07vjVkAAAAgDy8KgFAUEaSAAAAAJL3vgQAWdhcAgAAoBHDSUDo1pQAAACI2o4SAABZm14CALoyuwQAAPznGQkgZwdLAAAQukclAAAAAAAAAAAAgKbMKgEAAAAAAAAAAAAAAAAAAECftpYAAAAAAAAAAAAACnaXBAAAAADk7iMJAAAAAAAAAABqe00CAnGbBBG4TAIAgFDdKgFAXCaWAAAAAAAAAAAAAAAAAKAJQwR72XbGAQAAAKAhh0sAAAAAAABQgO8kAAAAAAAAAAAAACAaM0kAAAC5W0QCAIJ3mAQAxGwxCQAA6nhSAsjZBRIAANEbWQIAAAAAaJE3JACAwA0qAUBIVpKAlphbAiAPp0iQnKEkAAAAAAAgBP1KAAAAdOl4CQAAAAAAAPjLZBIAAG10RtrPm8_CAEBMTpYAAAAAAIjQYBL8z5QSAAAAAEDYPpUAACAsj0gAAADQkHMlAAjHDxIA0Lg9JQAAgHDsLQEAAABAQS6WAAAAgLjNFs2l_RgLAIAEfCEBlGZZCQAAaIHjJACgtlskAAAozb0SAAAAVFtfAgAAAAAAAAAAAAAAAAAAAAAAAKDDtxIAAAAAVZaTAKB5W0kAANCAsSUgJ0tL0GqHSNBbL0gAZflRAgCARG0kQXNmlgCABiwkAQAAAEB25pIAAAAAAAAAAAAAoFh9SwAAAAAAADWNmOSrpjFsEoaRgDKcF9Q1dxsEAAAAAAAAAAAAAAAAgPZ6SQIAAAAAAAAAgChMLgEAAAAAAAAAqZlQAsK2qQQAAAAAAAD06XUJAAAAqG9bCQAAgLD9IgEAAAAAAAAAAAAAAAAAAEBNe0gAAAAAAAAAAEBPHSEBAAAAlOZtCYA4fS8B0GFRCQAo0gISAOTgNwmC840EAAAAAAAAAAAAAAAAAAAAUJydJfjXPBIAAAAAAAAAAAAAAABk6WwJAAAAAAAAAAAAAAAAqG8UCQAAgPpOlAAAIA83SQAANWwc9HUjGAgAAAAAAACAusaSAAAAAAAAAAAAAAAAAAAAAAAAAAAAqHKVBACQjxklAAAAAAAAAKBHxpQAAAAAACBME0lAdlaUAACyt7sEAAAA0Nl0EgAAAAAAAAAAAABA-8wgAQAAAAAAAKU4SgKgUtlBAgAAAAAAAAAAgMCMLwEE51kJICdzSgCJGl2CsE0tAQAA0L11JQAAAAAAAAjUOhIAAAAAAAAAAAAAAGTqeQkAAAAAAAAAAAAAKM8SEjTrJwkAAAAAAACocqQEULgVJAAAACjDUxJUKgtKAAAAqbpRAgCA0n0mAQAAAABAGzwmAUCTLpUAAAAAAAAAAEjZNRIAAAAAAAAAAAAAAAAAAAAA8I-vJaAlhpQAAAAAAHrvzjJ-OqCuuVlLAojP8BJAr70sQZVDJYAgXS0BAAAAAAAAAAAAtMnyEgAAAAAAFONKCQAAAAAAAADorc0kAAAAAAAAgDqOlgAAAAAAAAAAAADIwv0SAAAAAAAAAAAAAADBuV0CIFVDSwAAAABAAI6RAAAAAGIwrQSEZAsJAABouRclAAAAAKDDrxIAAAA0bkkJgFiMKwEAAAAAAHQyhwRk7h4JAAAAAAAAAAAgatdKAACUYj0JAAAAAAAAAAAAQnORBLTFJRIAAAAAkIaDJAAAAJryngQAAAAAAAAAAAA98oQEAAAAAAAAAEC2zpcgWY9LQKL2kwAgGK9IAAAAAPHaRQIAAAAAAAAAAADIxyoSAAAAAAAAAAAAAADQFotLAECz_gQ1PX-B" + } +} diff --git a/tests/src/Codebooks/StatusTypeEnumTest.php b/tests/src/Codebooks/StatusTypeEnumTest.php new file mode 100644 index 0000000..520b4a2 --- /dev/null +++ b/tests/src/Codebooks/StatusTypeEnumTest.php @@ -0,0 +1,43 @@ +assertSame(0x00, StatusTypeEnum::Valid->value); + $this->assertSame(0x01, StatusTypeEnum::Invalid->value); + $this->assertSame(0x02, StatusTypeEnum::Suspended->value); + } + + + /** + * 0x03 and 0x0C to 0x0F are permanently reserved as application specific, and everything else is reserved for + * future registration, so none of them are Status Types of this library. + */ + public function testHasNoOtherValues(): void + { + $this->assertCount(3, StatusTypeEnum::cases()); + + foreach ([0x03, 0x04, 0x0C, 0x0F, 0xFF] as $value) { + $this->assertNull(StatusTypeEnum::tryFrom($value)); + } + } + + + public function testCanGetRequiredBits(): void + { + $this->assertSame(1, StatusTypeEnum::Valid->requiredBits()); + $this->assertSame(1, StatusTypeEnum::Invalid->requiredBits()); + // A one bit list can not carry a suspension, which is the trap this exists to make visible. + $this->assertSame(2, StatusTypeEnum::Suspended->requiredBits()); + } +} diff --git a/tests/src/Helpers/TypeTest.php b/tests/src/Helpers/TypeTest.php index 2416ea0..6010f17 100644 --- a/tests/src/Helpers/TypeTest.php +++ b/tests/src/Helpers/TypeTest.php @@ -7,6 +7,7 @@ use ArrayObject; use JsonSerializable; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Exceptions\InvalidValueException; use SimpleSAML\OpenID\Helpers\Type; @@ -245,6 +246,43 @@ public function testEnsureIntThrowsForNonNull(): void } + public function testCanEnsureNumber(): void + { + $this->assertSame(1, $this->sut()->ensureNumber(1)); + $this->assertSame(-1, $this->sut()->ensureNumber(-1)); + $this->assertEqualsWithDelta(1.5, $this->sut()->ensureNumber(1.5), PHP_FLOAT_EPSILON); + } + + + /** + * Unlike ensureInt(), a numeric string is not a number, which is what makes this usable for the JSON number + * a specification asks for. + */ + #[DataProvider('nonNumberProvider')] + public function testEnsureNumberThrowsForNonNumber(mixed $value): void + { + $this->expectException(InvalidValueException::class); + $this->expectExceptionMessage('not a number'); + + $this->sut()->ensureNumber($value); + } + + + /** + * @return \Iterator + */ + public static function nonNumberProvider(): \Iterator + { + yield 'numeric string' => ['1']; + yield 'string' => ['a']; + yield 'null' => [null]; + yield 'boolean' => [true]; + yield 'array' => [[1]]; + yield 'not a number' => [NAN]; + yield 'infinite' => [INF]; + } + + public function testCanEnforceRegex(): void { $this->assertSame('a', $this->sut()->enforceRegex('a', '/^a$/')); diff --git a/tests/src/Jws/JwsFetcherTest.php b/tests/src/Jws/JwsFetcherTest.php index 4eb8d0c..bec68d3 100644 --- a/tests/src/Jws/JwsFetcherTest.php +++ b/tests/src/Jws/JwsFetcherTest.php @@ -5,6 +5,7 @@ namespace SimpleSAML\Test\OpenID\Jws; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; @@ -159,6 +160,73 @@ public function getExpectedContentTypeHttpHeader(): string } + /** + * @return \Iterator + */ + public static function acceptableContentTypeProvider(): \Iterator + { + yield 'exact' => ['application/jwt']; + // RFC 9110 defines type and subtype as case-insensitive. + yield 'different case' => ['Application/JWT']; + yield 'with a parameter' => ['application/jwt; charset=UTF-8']; + yield 'with surrounding whitespace' => [' application/jwt ']; + } + + + #[DataProvider('acceptableContentTypeProvider')] + public function testAcceptsExpectedContentTypeHttpHeader(string $contentType): void + { + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->responseMock->method('getHeaderLine')->willReturn($contentType); + $this->parsedJwsFactoryMock->method('fromToken')->willReturn($this->parsedJwsMock); + + $this->assertSame($this->parsedJwsMock, $this->sutExpectingJwtContentType()->fromNetwork('uri')); + } + + + /** + * @return \Iterator + */ + public static function unacceptableContentTypeProvider(): \Iterator + { + yield 'empty' => ['']; + yield 'unrelated' => ['application/json']; + // Matching on a substring would let a lookalike through. + yield 'lookalike suffix' => ['application/jwtfoo']; + yield 'lookalike prefix' => ['xapplication/jwt']; + } + + + #[DataProvider('unacceptableContentTypeProvider')] + public function testRejectsUnexpectedContentTypeHttpHeader(string $contentType): void + { + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->responseMock->method('getHeaderLine')->willReturn($contentType); + + $this->expectException(FetchException::class); + $this->expectExceptionMessage('application/jwt'); + + $this->sutExpectingJwtContentType()->fromNetwork('uri'); + } + + + protected function sutExpectingJwtContentType(): JwsFetcher + { + return new class ( + $this->parsedJwsFactoryMock, + $this->artifactFetcherMock, + $this->maxCacheDurationMock, + $this->helpersMock, + $this->loggerMock, + ) extends JwsFetcher { + public function getExpectedContentTypeHttpHeader(): string + { + return 'application/jwt'; + } + }; + } + + public function testWillUseJwsExpirationTimeWhenConsideringTtlForCaching(): void { $expirationTime = time() + 60; diff --git a/tests/src/TokenStatusList/Factories/StatusListFactoryTest.php b/tests/src/TokenStatusList/Factories/StatusListFactoryTest.php new file mode 100644 index 0000000..72d8ee0 --- /dev/null +++ b/tests/src/TokenStatusList/Factories/StatusListFactoryTest.php @@ -0,0 +1,483 @@ +assertInstanceOf(StatusListFactory::class, $this->sut()); + } + + + public function testCanBuild(): void + { + $statusList = $this->sut()->build(1, "\xB9\xA3"); + + $this->assertSame(1, $statusList->getBits()); + $this->assertSame(16, $statusList->getCapacity()); + } + + + public function testCanBuildForCapacity(): void + { + $statusList = $this->sut()->forCapacity(1024, 2); + + $this->assertSame(1024, $statusList->getCapacity()); + $this->assertSame(256, strlen($statusList->getBytes())); + $this->assertSame(0, $statusList->get(0)); + $this->assertSame(0, $statusList->get(1023)); + } + + + /** + * A capacity that is not a whole number of bytes is rounded up, so the list conveys a status for a few more + * indices than requested rather than for fewer. + */ + public function testForCapacityRoundsUpToWholeBytes(): void + { + $this->assertSame(16, $this->sut()->forCapacity(9, 1)->getCapacity()); + $this->assertSame(4, $this->sut()->forCapacity(3, 2)->getCapacity()); + $this->assertSame(1, $this->sut()->forCapacity(1, 8)->getCapacity()); + } + + + public function testForCapacityThrowsForNonPositiveCapacity(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('capacity must be at least 1'); + + $this->sut()->forCapacity(0, 1); + } + + + public function testForCapacityThrowsForInvalidBits(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Invalid number of bits'); + + $this->sut()->forCapacity(1024, 3); + } + + + public function testCanBuildFromEntries(): void + { + $statusList = $this->sut()->fromEntries( + [ + 0 => StatusTypeEnum::Invalid, + 5 => StatusTypeEnum::Suspended, + 1023 => StatusTypeEnum::Invalid, + ], + 2, + 1024, + ); + + $this->assertSame(StatusTypeEnum::Invalid, $statusList->getStatusType(0)); + $this->assertSame(StatusTypeEnum::Valid, $statusList->getStatusType(1)); + $this->assertSame(StatusTypeEnum::Suspended, $statusList->getStatusType(5)); + $this->assertSame(StatusTypeEnum::Invalid, $statusList->getStatusType(1023)); + } + + + public function testCanBuildFromEntriesGivenAGenerator(): void + { + $entries = (function (): \Generator { + yield 3 => StatusTypeEnum::Invalid; + yield 9 => StatusTypeEnum::Invalid; + })(); + + $statusList = $this->sut()->fromEntries($entries, 1, 16); + + $this->assertSame(1, $statusList->get(3)); + $this->assertSame(1, $statusList->get(9)); + $this->assertSame(0, $statusList->get(4)); + } + + + public function testCanBuildFromEntriesGivenRawIntegerStatuses(): void + { + $statusList = $this->sut()->fromEntries([0 => 0b1111], 4, 16); + + $this->assertSame(0b1111, $statusList->get(0)); + } + + + public function testFromEntriesThrowsForOutOfRangeIndex(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('out of bounds'); + + $this->sut()->fromEntries([64 => StatusTypeEnum::Invalid], 1, 16); + } + + + public function testFromEntriesThrowsForNegativeIndex(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('out of bounds'); + + $this->sut()->fromEntries([-1 => StatusTypeEnum::Invalid], 1, 16); + } + + + /** + * A pool configured with 1 bit can never carry SUSPENDED, and reconfiguration can not retrofit an existing + * list, so this has to fail loudly rather than truncate. + */ + public function testFromEntriesThrowsForStatusThatDoesNotFit(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('can not be represented'); + + $this->sut()->fromEntries([0 => StatusTypeEnum::Suspended], 1, 16); + } + + + public function testFromEntriesThrowsForNonIntegerIndex(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('index must be an integer'); + + $this->sut()->fromEntries(['not-an-index' => StatusTypeEnum::Invalid], 1, 16); + } + + + public function testCanBuildFromEncoded(): void + { + $statusList = $this->sut()->fromEncoded(self::EXAMPLE_1BIT_LST, 1, 2); + + $this->assertSame("\xB9\xA3", $statusList->getBytes()); + $this->assertSame(16, $statusList->getCapacity()); + } + + + public function testCanRoundTrip(): void + { + $original = $this->sut()->fromEntries([0 => 1, 5 => 2, 300 => 3], 2, 1024); + + $decoded = $this->sut()->fromEncoded($original->toEncoded(), 2, 256); + + $this->assertSame($original->getBytes(), $decoded->getBytes()); + } + + + /** + * @return \Iterator + */ + public static function invalidEncodingProvider(): \Iterator + { + yield 'padded' => ['eNrbuRgAAhcBXQ==']; + yield 'standard base64 plus' => ['eNrbuRgAAhc+XQ']; + yield 'standard base64 slash' => ['eNrbuRgAAhc/XQ']; + yield 'leading whitespace' => [' eNrbuRgAAhcBXQ']; + yield 'embedded newline' => ["eNrbuRgA\nAhcBXQ"]; + // A regular expression anchored with $ rather than \z would let this one through, $ also matching + // immediately before a trailing newline. + yield 'trailing newline' => ["eNrbuRgAAhcBXQ\n"]; + yield 'empty' => ['']; + } + + + /** + * Only the one encoding the specification defines is accepted; nothing is normalised into shape first. + */ + #[DataProvider('invalidEncodingProvider')] + public function testFromEncodedThrowsForNonStrictBase64Url(string $lst): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('unpadded base64url'); + + $this->sut()->fromEncoded($lst, 1, 1024); + } + + + /** + * The unused bits of the final base64url quantum have to be zero. Left unchecked, base64_decode() ignores + * them and two different strings decode to the very same list. + */ + public function testFromEncodedThrowsForNonCanonicalPadBits(): void + { + // Differs from the canonical encoding only in the unused bits of its last character. + $this->assertSame( + (new Helpers())->base64Url()->decode('eNrbuRgAAhcBXR'), + (new Helpers())->base64Url()->decode(self::EXAMPLE_1BIT_LST), + ); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('not canonically encoded'); + + $this->sut()->fromEncoded('eNrbuRgAAhcBXR', 1, 1024); + } + + + /** + * gzuncompress() stops at the end of the first stream, so without an explicit check a list with bytes + * appended would decode to exactly the same statuses as the clean one. + */ + public function testFromEncodedThrowsForTrailingDataAfterTheStream(): void + { + $helpers = new Helpers(); + $compressed = gzcompress("\xB9\xA3", 9); + $this->assertIsString($compressed); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('trailing byte'); + + $this->sut()->fromEncoded($helpers->base64Url()->encode($compressed . 'TRAILING'), 1, 1024); + } + + + public function testFromEncodedThrowsForASecondAppendedStream(): void + { + $helpers = new Helpers(); + $compressed = gzcompress("\xB9\xA3", 9); + $this->assertIsString($compressed); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('trailing byte'); + + $this->sut()->fromEncoded($helpers->base64Url()->encode($compressed . $compressed), 1, 1024); + } + + + /** + * The default compressed bound must never be tighter than what this library's own encoder emits, or a list + * would fail to survive its own round trip. + */ + public function testDefaultCompressedBoundAcceptsThisLibrarysOwnWorstCaseOutput(): void + { + foreach ([1024, 16384, 73600, 131072] as $byteLength) { + $bytes = random_bytes($byteLength); + $encoded = $this->sut()->build(8, $bytes)->toEncoded(); + + $this->assertSame( + $bytes, + $this->sut()->fromEncoded($encoded, 8, $byteLength)->getBytes(), + sprintf('A %d byte list did not survive the round trip.', $byteLength), + ); + } + } + + + public function testFromEncodedThrowsForUndecompressableInput(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Unable to decompress'); + + $this->sut()->fromEncoded('bm90LXpsaWI', 1, 1024); + } + + + /** + * Raw DEFLATE without the ZLIB wrapper is not what the specification asks for. + */ + public function testFromEncodedThrowsForRawDeflateStream(): void + { + $rawDeflate = gzdeflate("\xB9\xA3", 9); + $this->assertIsString($rawDeflate); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Unable to decompress'); + + $this->sut()->fromEncoded( + (new Helpers())->base64Url()->encode($rawDeflate), + 1, + 1024, + ); + } + + + /** + * A stream that stops partway through is not a list. The inflater reports no error of its own for one, since + * as far as it knows the rest of the input was still to come. + */ + public function testFromEncodedThrowsForATruncatedStream(): void + { + $compressed = gzcompress(str_repeat("\xB9\xA3", 64), 9); + $this->assertIsString($compressed); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('does not hold a complete ZLIB stream'); + + $this->sut()->fromEncoded( + (new Helpers())->base64Url()->encode(substr($compressed, 0, strlen($compressed) - 4)), + 1, + 1024, + ); + } + + + /** + * A small compressed input decompressing to far more than the caller is willing to hold is refused before the + * memory is allocated. + */ + public function testFromEncodedThrowsForDecompressionBomb(): void + { + $bomb = gzcompress(str_repeat("\x00", 10 * 1024 * 1024), 9); + $this->assertIsString($bomb); + + $encoded = (new Helpers())->base64Url()->encode($bomb); + + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('decompresses to more than 1024 bytes'); + + $this->sut()->fromEncoded($encoded, 1, 1024, strlen($bomb)); + } + + + /** + * The encoded length is checked before anything is decoded. + */ + public function testFromEncodedThrowsForOversizedEncodedInput(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('exceeds the maximum of'); + + $this->sut()->fromEncoded(str_repeat('A', 1000), 1, 8, 10); + } + + + /** + * ... and the decoded length is checked before decompression is attempted. + */ + public function testFromEncodedThrowsForOversizedCompressedInput(): void + { + // 16 base64url characters decode to 12 bytes, which passes the encoded-length bound of 16 for a + // maximum of 10 compressed bytes but not the compressed-length bound itself. + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Compressed Status List is 12 bytes long'); + + $this->sut()->fromEncoded(str_repeat('A', 16), 1, 8, 10); + } + + + public function testFromEncodedThrowsForNonPositiveMaxDecompressedBytes(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('decompressed size must be at least 1 byte'); + + $this->sut()->fromEncoded(self::EXAMPLE_1BIT_LST, 1, 0); + } + + + public function testFromEncodedThrowsForNonPositiveMaxCompressedBytes(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('compressed size must be at least 1 byte'); + + $this->sut()->fromEncoded(self::EXAMPLE_1BIT_LST, 1, 1024, 0); + } + + + /** + * The default compressed bound accommodates a list that does not compress at all, which is what a list full of + * unique 8-bit statuses approaches. + */ + public function testDefaultCompressedBoundAcceptsIncompressibleInput(): void + { + $bytes = random_bytes(65536); + $encoded = $this->sut()->build(8, $bytes)->toEncoded(); + + $this->assertSame($bytes, $this->sut()->fromEncoded($encoded, 8, 65536)->getBytes()); + } + + + public function testCanBuildFromClaimData(): void + { + $statusList = $this->sut()->fromClaimData( + ['bits' => 1, 'lst' => self::EXAMPLE_1BIT_LST], + 1024, + ); + + $this->assertSame(1, $statusList->getBits()); + $this->assertSame("\xB9\xA3", $statusList->getBytes()); + $this->assertNull($statusList->getAggregationUri()); + } + + + public function testCanBuildFromClaimDataWithAggregationUri(): void + { + $statusList = $this->sut()->fromClaimData( + [ + 'bits' => 1, + 'lst' => self::EXAMPLE_1BIT_LST, + 'aggregation_uri' => 'https://example.org/statuslists', + ], + 1024, + ); + + $this->assertSame('https://example.org/statuslists', $statusList->getAggregationUri()); + } + + + /** + * @return \Iterator + */ + public static function invalidBitsClaimProvider(): \Iterator + { + yield 'missing' => [null]; + yield 'numeric string' => ['1']; + yield 'float' => [1.0]; + yield 'boolean' => [true]; + yield 'array' => [[1]]; + } + + + /** + * `bits` is a JSON Integer, so a numeric string does not satisfy it. + */ + #[DataProvider('invalidBitsClaimProvider')] + public function testFromClaimDataThrowsForNonIntegerBits(mixed $bits): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('bits claim must be an integer'); + + $this->sut()->fromClaimData(['bits' => $bits, 'lst' => self::EXAMPLE_1BIT_LST], 1024); + } + + + public function testFromClaimDataThrowsForMissingList(): void + { + $this->expectException(InvalidValueException::class); + + $this->sut()->fromClaimData(['bits' => 1], 1024); + } + + + public function testFromClaimDataThrowsForUnsupportedBits(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Invalid number of bits'); + + $this->sut()->fromClaimData(['bits' => 3, 'lst' => self::EXAMPLE_1BIT_LST], 1024); + } +} diff --git a/tests/src/TokenStatusList/Factories/StatusListTokenFactoryTest.php b/tests/src/TokenStatusList/Factories/StatusListTokenFactoryTest.php new file mode 100644 index 0000000..f459b89 --- /dev/null +++ b/tests/src/TokenStatusList/Factories/StatusListTokenFactoryTest.php @@ -0,0 +1,303 @@ +helpers = new Helpers(); + $this->statusListFactory = new StatusListFactory($this->helpers); + $this->signingKey = new JwkDecorator(JWKFactory::createECKey('P-256')); + } + + + protected function sut(): StatusListTokenFactory + { + $supportedAlgorithms = new SupportedAlgorithms( + new SignatureAlgorithmBag(SignatureAlgorithmEnum::ES256), + ); + $jwsSerializerManagerDecorator = (new JwsSerializerManagerDecoratorFactory()) + ->build(new SupportedSerializers()); + $algorithmManagerDecorator = (new AlgorithmManagerDecoratorFactory())->build($supportedAlgorithms); + + return new StatusListTokenFactory( + (new JwsDecoratorBuilderFactory())->build( + $jwsSerializerManagerDecorator, + $algorithmManagerDecorator, + $this->helpers, + ), + (new JwsVerifierDecoratorFactory())->build($algorithmManagerDecorator), + new JwksDecoratorFactory(), + $jwsSerializerManagerDecorator, + new DateIntervalDecorator(new DateInterval('PT1M')), + $this->helpers, + new ClaimFactory($this->helpers), + $this->statusListFactory, + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusListTokenFactory::class, $this->sut()); + } + + + public function testCanBuildForStatusList(): void + { + $issuedAt = new DateTimeImmutable('@1686920170'); + $expiresAt = new DateTimeImmutable('@2291720170'); + + $statusListToken = $this->sut()->forStatusList( + $this->statusListFactory->build(1, "\xB9\xA3"), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + $issuedAt, + $expiresAt, + new DateInterval('PT12H'), + ); + + $this->assertSame('statuslist+jwt', $statusListToken->getType()); + $this->assertSame(self::URI, $statusListToken->getSubject()); + $this->assertSame(1686920170, $statusListToken->getIssuedAt()); + $this->assertSame(2291720170, $statusListToken->getExpirationTime()); + $this->assertSame(43200, $statusListToken->getTimeToLive()); + $this->assertSame( + ['bits' => 1, 'lst' => 'eNrbuRgAAhcBXQ'], + $statusListToken->getStatusListClaimData(), + ); + $this->assertNull($statusListToken->getIssuer()); + } + + + /** + * Expiration time, ttl and issuer are all optional, and issued at defaults to now. + */ + public function testCanBuildForStatusListWithOnlyRequiredClaims(): void + { + $statusListToken = $this->sut()->forStatusList( + $this->statusListFactory->forCapacity(1024, 1), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + ); + + $this->assertNull($statusListToken->getExpirationTime()); + $this->assertNull($statusListToken->getTimeToLive()); + $this->assertNull($statusListToken->getIssuer()); + $this->assertEqualsWithDelta(time(), $statusListToken->getIssuedAt(), 5); + } + + + public function testCanBuildForStatusListWithIssuer(): void + { + $statusListToken = $this->sut()->forStatusList( + $this->statusListFactory->forCapacity(1024, 1), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + null, + null, + null, + 'https://example.com', + ); + + $this->assertSame('https://example.com', $statusListToken->getIssuer()); + } + + + /** + * The key identifier is a header claim the caller supplies, since the specification mandates no method for + * binding a token to a key. + */ + public function testCanBuildForStatusListWithAdditionalClaims(): void + { + $statusListToken = $this->sut()->forStatusList( + $this->statusListFactory->forCapacity(1024, 1), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + null, + null, + null, + null, + ['custom' => 'payload-claim'], + ['kid' => 'did:jwk:example#0'], + ); + + $this->assertSame('did:jwk:example#0', $statusListToken->getKeyId()); + $this->assertSame('payload-claim', $statusListToken->getPayloadClaim('custom')); + } + + + /** + * The type header is set by the factory, so a caller can not accidentally issue a token some other type. + */ + public function testTypeHeaderCanNotBeOverridden(): void + { + $statusListToken = $this->sut()->fromData( + $this->signingKey, + SignatureAlgorithmEnum::ES256, + [ + 'sub' => self::URI, + 'iat' => time(), + 'status_list' => ['bits' => 1, 'lst' => 'eNrbuRgAAhcBXQ'], + ], + ['typ' => 'JWT'], + ); + + $this->assertSame('statuslist+jwt', $statusListToken->getType()); + } + + + public function testCanBuildFromToken(): void + { + $sut = $this->sut(); + + $token = $sut->forStatusList( + $this->statusListFactory->build(1, "\xB9\xA3"), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + )->getToken(); + + $parsed = $sut->fromToken($token); + + $this->assertInstanceOf(StatusListToken::class, $parsed); + $this->assertSame(self::URI, $parsed->getSubject()); + $this->assertSame("\xB9\xA3", $parsed->getStatusList(1024)->getBytes()); + } + + + /** + * The specification serves the JWT representation as a JWS Compact Serialization, so a JSON serialized JWS + * is not a Status List Token -- not even where a deployment has enabled those serializers for other token + * types, which would otherwise let one through carrying signatures and unprotected headers that nothing + * here accounts for. + */ + public function testFromTokenRejectsAJsonSerializedJws(): void + { + $token = $this->sut()->forStatusList( + $this->statusListFactory->build(1, "\xB9\xA3"), + self::URI, + $this->signingKey, + SignatureAlgorithmEnum::ES256, + )->getToken(); + + [$header, $payload, $signature] = explode('.', $token); + + $jsonSerialized = json_encode([ + 'protected' => $header, + 'payload' => $payload, + 'signature' => $signature, + ]); + $this->assertIsString($jsonSerialized); + + $this->expectException(StatusListTokenException::class); + $this->expectExceptionMessage('not a JWS Compact Serialization'); + + $this->sut()->fromToken($jsonSerialized); + } + + + /** + * @return \Iterator + */ + public static function nonCompactTokenProvider(): \Iterator + { + yield 'empty' => ['']; + yield 'two segments' => ['aGVhZGVy.cGF5bG9hZA']; + yield 'four segments' => ['aGVhZGVy.cGF5bG9hZA.c2ln.ZXh0cmE']; + yield 'an empty segment' => ['aGVhZGVy..c2ln']; + yield 'padded base64' => ['aGVhZGVy.cGF5bG9hZA==.c2ln']; + yield 'trailing newline' => ["aGVhZGVy.cGF5bG9hZA.c2ln\n"]; + yield 'surrounding whitespace' => [' aGVhZGVy.cGF5bG9hZA.c2ln ']; + } + + + #[DataProvider('nonCompactTokenProvider')] + public function testFromTokenRejectsMalformedSerializations(string $token): void + { + $this->expectException(StatusListTokenException::class); + $this->expectExceptionMessage('not a JWS Compact Serialization'); + + $this->sut()->fromToken($token); + } +} diff --git a/tests/src/TokenStatusList/Factories/StatusReferenceFactoryTest.php b/tests/src/TokenStatusList/Factories/StatusReferenceFactoryTest.php new file mode 100644 index 0000000..cb15c9c --- /dev/null +++ b/tests/src/TokenStatusList/Factories/StatusReferenceFactoryTest.php @@ -0,0 +1,179 @@ +assertInstanceOf(StatusReferenceFactory::class, $this->sut()); + } + + + public function testCanBuild(): void + { + $statusReference = $this->sut()->build('https://example.com/statuslists/1', 7); + + $this->assertSame('https://example.com/statuslists/1', $statusReference->getUri()); + $this->assertSame(7, $statusReference->getIdx()); + } + + + public function testCanBuildClaim(): void + { + $statusClaim = $this->sut()->buildClaim('https://example.com/statuslists/1', 7); + + $this->assertInstanceOf(StatusClaim::class, $statusClaim); + $this->assertSame(7, $statusClaim->getStatusReference()->getIdx()); + } + + + public function testCanBuildFromStatusListClaimData(): void + { + $statusReference = $this->sut()->fromStatusListClaimData( + ['idx' => 0, 'uri' => 'https://example.com/statuslists/1'], + ); + + $this->assertSame('https://example.com/statuslists/1', $statusReference->getUri()); + $this->assertSame(0, $statusReference->getIdx()); + } + + + /** + * The example Referenced Token payload from the specification. + */ + public function testCanBuildFromReferencedTokenPayload(): void + { + $statusReference = $this->sut()->fromReferencedTokenPayload([ + 'iss' => 'https://example.com/issuer', + 'iat' => 1683000000, + 'status' => [ + 'status_list' => [ + 'idx' => 0, + 'uri' => 'https://example.com/statuslists/1', + ], + ], + ]); + + $this->assertInstanceOf(StatusReference::class, $statusReference); + $this->assertSame('https://example.com/statuslists/1', $statusReference->getUri()); + $this->assertSame(0, $statusReference->getIdx()); + } + + + /** + * A token without a status claim is not malformed; the Relying Party decides what to make of it. + */ + public function testReturnsNullWhenPayloadHasNoStatusClaim(): void + { + $this->assertNotInstanceOf( + StatusReference::class, + $this->sut()->fromReferencedTokenPayload(['iss' => 'https://example.com/issuer']), + ); + } + + + /** + * @return \Iterator + */ + public static function invalidIdxProvider(): \Iterator + { + yield 'missing' => [null]; + yield 'numeric string' => ['0']; + yield 'float' => [0.0]; + yield 'boolean' => [false]; + yield 'array' => [[0]]; + } + + + #[DataProvider('invalidIdxProvider')] + public function testThrowsForNonIntegerIdx(mixed $idx): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('index claim must be an integer'); + + $this->sut()->fromStatusListClaimData(['idx' => $idx, 'uri' => 'https://example.com/statuslists/1']); + } + + + public function testThrowsForNegativeIdx(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('non-negative integer'); + + $this->sut()->fromStatusListClaimData(['idx' => -1, 'uri' => 'https://example.com/statuslists/1']); + } + + + public function testThrowsForMissingUri(): void + { + $this->expectException(InvalidValueException::class); + + $this->sut()->fromStatusListClaimData(['idx' => 0]); + } + + + public function testThrowsForNonUri(): void + { + $this->expectException(InvalidValueException::class); + + $this->sut()->fromStatusListClaimData(['idx' => 0, 'uri' => 'not a uri']); + } + + + /** + * A claim which is present but null is a malformed Referenced Token, not one that simply carries no status. + */ + public function testThrowsForNullStatusClaim(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Status claim must be an object'); + + $this->sut()->fromReferencedTokenPayload(['status' => null]); + } + + + public function testThrowsForNonObjectStatusClaim(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Status claim must be an object'); + + $this->sut()->fromReferencedTokenPayload(['status' => 'revoked']); + } + + + /** + * A status claim referencing some other mechanism is not silently treated as an absent status. + */ + public function testThrowsForStatusClaimWithoutStatusListMember(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Status List claim must be an object'); + + $this->sut()->fromReferencedTokenPayload(['status' => ['some_other_mechanism' => ['a' => 'b']]]); + } +} diff --git a/tests/src/TokenStatusList/StatusClaimTest.php b/tests/src/TokenStatusList/StatusClaimTest.php new file mode 100644 index 0000000..efcd336 --- /dev/null +++ b/tests/src/TokenStatusList/StatusClaimTest.php @@ -0,0 +1,74 @@ +assertInstanceOf(StatusClaim::class, $this->sut()); + } + + + public function testCanGetProperties(): void + { + $statusReference = new StatusReference('https://example.com/statuslists/1', 5, new Helpers()); + + $this->assertSame($statusReference, $this->sut($statusReference)->getStatusReference()); + $this->assertSame('status', $this->sut()->getName()); + } + + + public function testCanGetValue(): void + { + $this->assertSame( + [ + 'status_list' => [ + 'idx' => 0, + 'uri' => 'https://example.com/statuslists/1', + ], + ], + $this->sut()->getValue(), + ); + } + + + /** + * Reproduces the claim structure the specification shows for a Referenced Token. + */ + public function testCanJsonSerialize(): void + { + $this->assertSame( + [ + 'status' => [ + 'status_list' => [ + 'idx' => 0, + 'uri' => 'https://example.com/statuslists/1', + ], + ], + ], + $this->sut()->jsonSerialize(), + ); + } +} diff --git a/tests/src/TokenStatusList/StatusListTest.php b/tests/src/TokenStatusList/StatusListTest.php new file mode 100644 index 0000000..9c45d52 --- /dev/null +++ b/tests/src/TokenStatusList/StatusListTest.php @@ -0,0 +1,344 @@ +assertInstanceOf(StatusList::class, $this->sut()); + } + + + public function testCanGetProperties(): void + { + $sut = $this->sut(); + + $this->assertSame(1, $sut->getBits()); + $this->assertSame(self::EXAMPLE_1BIT_BYTES, $sut->getBytes()); + $this->assertSame(16, $sut->getCapacity()); + $this->assertNull($sut->getAggregationUri()); + } + + + /** + * @return \Iterator + */ + public static function capacityProvider(): \Iterator + { + yield '1 bit, 8 entries per byte' => [1, 8]; + yield '2 bits, 4 entries per byte' => [2, 4]; + yield '4 bits, 2 entries per byte' => [4, 2]; + yield '8 bits, 1 entry per byte' => [8, 1]; + } + + + #[DataProvider('capacityProvider')] + public function testCapacityFollowsFromBits(int $bits, int $entriesPerByte): void + { + $this->assertSame($entriesPerByte * 3, $this->sut($bits, "\x00\x00\x00")->getCapacity()); + } + + + /** + * The bit values of the 1-bit example in the specification, in index order. + */ + public function testDecodesTheOneBitSpecificationExample(): void + { + $expected = [1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 1]; + + $sut = $this->sut(1, self::EXAMPLE_1BIT_BYTES); + + foreach ($expected as $idx => $status) { + $this->assertSame($status, $sut->get($idx), sprintf('Index %d.', $idx)); + } + } + + + /** + * The 2-bit example, which is where a reversed packing order stops being ambiguous. + */ + public function testDecodesTheTwoBitSpecificationExample(): void + { + $expected = [0b01, 0b10, 0b00, 0b11, 0b00, 0b01, 0b00, 0b01, 0b01, 0b10, 0b11, 0b11]; + + $sut = $this->sut(2, self::EXAMPLE_2BIT_BYTES); + + foreach ($expected as $idx => $status) { + $this->assertSame($status, $sut->get($idx), sprintf('Index %d.', $idx)); + } + } + + + public function testCanGetStatusType(): void + { + $sut = $this->sut(2, self::EXAMPLE_2BIT_BYTES); + + $this->assertSame(StatusTypeEnum::Invalid, $sut->getStatusType(0)); + $this->assertSame(StatusTypeEnum::Suspended, $sut->getStatusType(1)); + $this->assertSame(StatusTypeEnum::Valid, $sut->getStatusType(2)); + } + + + /** + * 0x03 is permanently reserved as application specific, so it has no Status Type of its own. + */ + public function testGetStatusTypeIsNullForUnregisteredValue(): void + { + $sut = $this->sut(2, self::EXAMPLE_2BIT_BYTES); + + $this->assertNotInstanceOf(StatusTypeEnum::class, $sut->getStatusType(3)); + $this->assertSame(0b11, $sut->get(3)); + } + + + public function testCanSetStatus(): void + { + $sut = $this->sut(2, "\x00"); + + $updated = $sut->withStatus(2, StatusTypeEnum::Suspended); + + $this->assertSame(StatusTypeEnum::Suspended, $updated->getStatusType(2)); + // Immutable, so the original is untouched. + $this->assertSame(StatusTypeEnum::Valid, $sut->getStatusType(2)); + } + + + public function testSettingStatusLeavesNeighbouringIndicesAlone(): void + { + $sut = $this->sut(4, "\xFF\xFF") + ->withStatus(1, 0b0000); + + $this->assertSame(0b1111, $sut->get(0)); + $this->assertSame(0b0000, $sut->get(1)); + $this->assertSame(0b1111, $sut->get(2)); + $this->assertSame(0b1111, $sut->get(3)); + } + + + public function testCanOverwriteAnExistingStatus(): void + { + $sut = $this->sut(4, "\x00") + ->withStatus(0, 0b1010) + ->withStatus(0, 0b0101); + + $this->assertSame(0b0101, $sut->get(0)); + } + + + /** + * @return \Iterator + */ + public static function invalidBitsProvider(): \Iterator + { + yield 'zero' => [0]; + yield 'negative' => [-1]; + yield 'three' => [3]; + yield 'five' => [5]; + yield 'sixteen' => [16]; + } + + + #[DataProvider('invalidBitsProvider')] + public function testThrowsForInvalidBits(int $bits): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('Invalid number of bits'); + + $this->sut($bits); + } + + + public function testThrowsForEmptyByteArray(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('at least one index'); + + $this->sut(1, ''); + } + + + public function testThrowsForNonUriAggregationUri(): void + { + $this->expectException(InvalidValueException::class); + + $this->sut(1, self::EXAMPLE_1BIT_BYTES, 'not a uri'); + } + + + /** + * @return \Iterator + */ + public static function outOfRangeIndexProvider(): \Iterator + { + yield 'negative' => [-1]; + yield 'one past the end' => [16]; + yield 'far past the end' => [100000]; + } + + + /** + * An index outside the list is not VALID: no statement about the Referenced Token can be made. + */ + #[DataProvider('outOfRangeIndexProvider')] + public function testGetThrowsForOutOfRangeIndex(int $idx): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('out of bounds'); + + $this->sut()->get($idx); + } + + + #[DataProvider('outOfRangeIndexProvider')] + public function testWithStatusThrowsForOutOfRangeIndex(int $idx): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('out of bounds'); + + $this->sut()->withStatus($idx, 1); + } + + + /** + * @return \Iterator + */ + public static function oversizedStatusProvider(): \Iterator + { + yield '1 bit can not hold 2' => [1, 2]; + yield '1 bit can not hold SUSPENDED' => [1, StatusTypeEnum::Suspended->value]; + yield '2 bits can not hold 4' => [2, 4]; + yield '4 bits can not hold 16' => [4, 16]; + yield '8 bits can not hold 256' => [8, 256]; + } + + + #[DataProvider('oversizedStatusProvider')] + public function testWithStatusThrowsForStatusThatDoesNotFit(int $bits, int $status): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('can not be represented'); + + $this->sut($bits, "\x00\x00")->withStatus(0, $status); + } + + + public function testWithStatusThrowsForNegativeStatus(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('can not be represented'); + + $this->sut(8, "\x00")->withStatus(0, -1); + } + + + /** + * @return \Iterator + */ + public static function encodingProvider(): \Iterator + { + yield '1 bit' => [1, self::EXAMPLE_1BIT_BYTES, self::EXAMPLE_1BIT_LST]; + yield '2 bit' => [2, self::EXAMPLE_2BIT_BYTES, self::EXAMPLE_2BIT_LST]; + } + + + /** + * Reproduces the encoded lists the specification prints for its own examples. + */ + #[DataProvider('encodingProvider')] + public function testCanEncode(int $bits, string $bytes, string $lst): void + { + $this->assertSame($lst, $this->sut($bits, $bytes)->toEncoded()); + } + + + #[DataProvider('encodingProvider')] + public function testCanJsonSerialize(int $bits, string $bytes, string $lst): void + { + $this->assertSame( + ['bits' => $bits, 'lst' => $lst], + $this->sut($bits, $bytes)->jsonSerialize(), + ); + } + + + public function testJsonSerializeIncludesAggregationUriWhenSet(): void + { + $sut = $this->sut(1, self::EXAMPLE_1BIT_BYTES, 'https://example.org/statuslists'); + + $this->assertSame( + [ + 'bits' => 1, + 'lst' => self::EXAMPLE_1BIT_LST, + 'aggregation_uri' => 'https://example.org/statuslists', + ], + $sut->jsonSerialize(), + ); + } + + + public function testAggregationUriSurvivesWithStatus(): void + { + $sut = $this->sut(1, self::EXAMPLE_1BIT_BYTES, 'https://example.org/statuslists') + ->withStatus(0, 0); + + $this->assertSame('https://example.org/statuslists', $sut->getAggregationUri()); + } + + + public function testIsAllowedBits(): void + { + foreach ([1, 2, 4, 8] as $bits) { + $this->assertTrue(StatusList::isAllowedBits($bits)); + } + + foreach ([0, -1, 3, 5, 7, 9, 16] as $bits) { + $this->assertFalse(StatusList::isAllowedBits($bits)); + } + } +} diff --git a/tests/src/TokenStatusList/StatusListTestVectorsTest.php b/tests/src/TokenStatusList/StatusListTestVectorsTest.php new file mode 100644 index 0000000..5a4c342 --- /dev/null +++ b/tests/src/TokenStatusList/StatusListTestVectorsTest.php @@ -0,0 +1,225 @@ +,lst:string}> + */ + protected static array $testVectors; + + protected static StatusListFactory $statusListFactory; + + + public static function setUpBeforeClass(): void + { + $json = file_get_contents( + (new Help())->getTestDataDir('status-list-test-vectors-appendix-c.json'), + ); + + self::$testVectors = (array)json_decode((string)$json, true, 512, JSON_THROW_ON_ERROR); + self::$statusListFactory = new StatusListFactory(new Helpers()); + } + + + /** + * @return \Iterator + */ + public static function vectorNameProvider(): \Iterator + { + yield 'C.1 (1-bit)' => ['C.1']; + yield 'C.2 (2-bit)' => ['C.2']; + yield 'C.3 (4-bit)' => ['C.3']; + yield 'C.4 (8-bit)' => ['C.4']; + } + + + public function testCanLoadTestVectors(): void + { + $this->assertCount(4, self::$testVectors); + } + + + /** + * Every index the specification asserts a status for decodes to that status, and the list is the size the + * vector says it is. + */ + #[DataProvider('vectorNameProvider')] + public function testDecodesEveryAssertedIndex(string $name): void + { + $vector = self::$testVectors[$name]; + + $statusList = self::$statusListFactory->fromEncoded( + $vector['lst'], + $vector['bits'], + $vector['byteLength'], + ); + + $this->assertSame($vector['byteLength'], strlen($statusList->getBytes())); + $this->assertSame($vector['capacity'], $statusList->getCapacity()); + + foreach ($vector['statuses'] as $idx => $expectedStatus) { + $this->assertSame( + $expectedStatus, + $statusList->get((int)$idx), + sprintf('%s: index %s decoded to the wrong status.', $name, $idx), + ); + } + } + + + /** + * Indices the vector says nothing about read as 0 (VALID). Appendix C states that all values not mentioned + * can be assumed to be 0, so a decoder that shifted statuses would be caught here as well as above. + */ + #[DataProvider('vectorNameProvider')] + public function testUnlistedIndicesAreValid(string $name): void + { + $vector = self::$testVectors[$name]; + + $statusList = self::$statusListFactory->fromEncoded( + $vector['lst'], + $vector['bits'], + $vector['byteLength'], + ); + + $asserted = array_map(intval(...), array_keys($vector['statuses'])); + + // Neighbours of asserted indices are where an off-by-one in the packing shows up first. + $probes = []; + foreach ($asserted as $idx) { + $probes[] = $idx - 1; + $probes[] = $idx + 1; + } + + $probes = array_merge($probes, [1, 2, 3, 7, 8, 9, 100, 65535, $vector['capacity'] - 1]); + + foreach ($probes as $idx) { + if ($idx < 0) { + continue; + } + + if ($idx >= $vector['capacity']) { + continue; + } + + if (in_array($idx, $asserted, true)) { + continue; + } + + $this->assertSame( + 0, + $statusList->get($idx), + sprintf('%s: unlisted index %d did not decode as VALID.', $name, $idx), + ); + } + } + + + /** + * Rebuilding the list from its asserted entries reproduces the exact `lst` string the specification publishes. + * + * This is a canary rather than a conformance requirement: the specification only RECOMMENDS the highest + * compression level, and decoders must accept any valid DEFLATE stream, so a zlib whose output differs is not + * a defect. It does mean this library's encoder still agrees with the reference implementation byte for byte. + */ + #[DataProvider('vectorNameProvider')] + public function testEncoderReproducesSpecificationBytes(string $name): void + { + $vector = self::$testVectors[$name]; + + $entries = []; + foreach ($vector['statuses'] as $idx => $status) { + $entries[(int)$idx] = $status; + } + + $statusList = self::$statusListFactory->fromEntries( + $entries, + $vector['bits'], + $vector['capacity'], + ); + + $this->assertSame($vector['lst'], $statusList->toEncoded()); + } + + + /** + * fromEntries() and repeated withStatus() produce the same list. + */ + #[DataProvider('vectorNameProvider')] + public function testBulkMaterialisationMatchesRepeatedWithStatus(string $name): void + { + $vector = self::$testVectors[$name]; + + $entries = []; + foreach ($vector['statuses'] as $idx => $status) { + $entries[(int)$idx] = $status; + } + + $bulk = self::$statusListFactory->fromEntries($entries, $vector['bits'], $vector['capacity']); + + $applied = self::$statusListFactory->forCapacity($vector['capacity'], $vector['bits']); + foreach ($entries as $idx => $status) { + $applied = $applied->withStatus($idx, $status); + } + + $this->assertSame($bulk->getBytes(), $applied->getBytes()); + } + + + /** + * A gap in the entries leaves that index VALID instead of shifting every status after it. Keying on iteration + * position rather than on the supplied index would silently misreport the status of every later Referenced + * Token, so this is asserted directly. + */ + public function testEntriesAreKeyedOnIndexNotOnPosition(): void + { + $vector = self::$testVectors['C.1']; + + $entries = []; + foreach ($vector['statuses'] as $idx => $status) { + $entries[(int)$idx] = $status; + } + + // Drop the first asserted index, keeping the rest at the indices they belong to. Note that array_shift() + // would renumber the integer keys and so defeat the point of the test. + $firstIdx = (int)array_key_first($entries); + $withoutFirst = $entries; + unset($withoutFirst[$firstIdx]); + + $statusList = self::$statusListFactory->fromEntries( + $withoutFirst, + $vector['bits'], + $vector['capacity'], + ); + + $this->assertSame(0, $statusList->get($firstIdx)); + + foreach ($withoutFirst as $idx => $expectedStatus) { + $this->assertSame($expectedStatus, $statusList->get($idx)); + } + } +} diff --git a/tests/src/TokenStatusList/StatusListTokenFetcherTest.php b/tests/src/TokenStatusList/StatusListTokenFetcherTest.php new file mode 100644 index 0000000..aebc357 --- /dev/null +++ b/tests/src/TokenStatusList/StatusListTokenFetcherTest.php @@ -0,0 +1,298 @@ +statusListTokenFactoryMock = $this->createMock(StatusListTokenFactory::class); + $this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class); + + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->responseMock->method('getHeaderLine') + ->willReturn(ContentTypesEnum::ApplicationStatusListJwt->value); + $this->artifactFetcherMock->method('fromNetwork')->willReturn($this->responseMock); + $this->artifactFetcherMock->method('readResponseBodyAsString')->willReturn('token'); + + $this->statusListTokenMock = $this->createMock(StatusListToken::class); + $this->statusListTokenMock->method('getToken')->willReturn('token'); + $this->statusListTokenFactoryMock->method('fromToken')->willReturn($this->statusListTokenMock); + } + + + protected function sut(): StatusListTokenFetcher + { + return new StatusListTokenFetcher( + $this->statusListTokenFactoryMock, + $this->artifactFetcherMock, + $this->maxCacheDurationMock, + $this->createStub(\SimpleSAML\OpenID\Helpers::class), + $this->createStub(\Psr\Log\LoggerInterface::class), + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusListTokenFetcher::class, $this->sut()); + } + + + /** + * The specification defines a media type for the response, so a response of some other type is not a Status + * List Token. + */ + public function testHasRightExpectedContentTypeHttpHeader(): void + { + $this->assertSame( + ContentTypesEnum::ApplicationStatusListJwt->value, + $this->sut()->getExpectedContentTypeHttpHeader(), + ); + } + + + public function testCanFetchFromCache(): void + { + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->willReturn('token'); + + $this->assertSame($this->statusListTokenMock, $this->sut()->fromCache(self::URI)); + } + + + public function testReturnsNullWhenNotCached(): void + { + $this->artifactFetcherMock->method('fromCacheAsString')->willReturn(null); + + $this->assertNotInstanceOf(StatusListToken::class, $this->sut()->fromCache(self::URI)); + } + + + public function testFallsBackToNetworkWhenNotCached(): void + { + $this->artifactFetcherMock->method('fromCacheAsString')->willReturn(null); + $this->artifactFetcherMock->expects($this->once())->method('fromNetwork'); + + $this->assertSame($this->statusListTokenMock, $this->sut()->fromCacheOrNetwork(self::URI)); + } + + + /** + * The specification expects the JWT and CWT representations to be chosen between by content negotiation, and + * this fetcher only understands the JWT one, so a provider defaulting to CWT has to be told. + */ + public function testAsksForTheJwtRepresentation(): void + { + $this->artifactFetcherMock->method('fromCacheAsString')->willReturn(null); + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetwork') + ->with( + self::URI, + HttpMethodsEnum::GET, + ['headers' => ['Accept' => ContentTypesEnum::ApplicationStatusListJwt->value]], + ) + ->willReturn($this->responseMock); + + $this->sut()->fromCacheOrNetwork(self::URI); + } + + + /** + * Including on a direct network fetch, which a caller forcing a refresh reaches for. + */ + public function testAsksForTheJwtRepresentationOnADirectNetworkFetch(): void + { + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetwork') + ->with( + self::URI, + HttpMethodsEnum::GET, + ['headers' => ['Accept' => ContentTypesEnum::ApplicationStatusListJwt->value]], + ) + ->willReturn($this->responseMock); + + $this->sut()->fromNetwork(self::URI); + } + + + /** + * A caller that has its own Accept header in mind keeps it. + */ + public function testDoesNotOverrideACallerSuppliedAcceptHeader(): void + { + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetwork') + ->with(self::URI, HttpMethodsEnum::GET, ['headers' => ['Accept' => 'application/jwt']]) + ->willReturn($this->responseMock); + + $this->sut()->fromNetwork(self::URI, HttpMethodsEnum::GET, ['headers' => ['Accept' => 'application/jwt']]); + } + + + /** + * Header names are case-insensitive, so a caller's lowercase `accept` is not a different header that ours + * can be added alongside. + */ + public function testDoesNotAddASecondAcceptHeaderInAnotherCase(): void + { + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetwork') + ->with(self::URI, HttpMethodsEnum::GET, ['headers' => ['accept' => 'application/jwt']]) + ->willReturn($this->responseMock); + + $this->sut()->fromNetwork(self::URI, HttpMethodsEnum::GET, ['headers' => ['accept' => 'application/jwt']]); + } + + + /** + * Nothing about a fetched token is trustworthy until its signature and subject have been checked, and this + * fetcher holds no key material to check them with, so it offers a path that does not cache. + */ + public function testCanFetchFromNetworkWithoutCaching(): void + { + $this->artifactFetcherMock->expects($this->never())->method('cacheIt'); + + $this->assertSame($this->statusListTokenMock, $this->sut()->fromNetworkWithoutCaching(self::URI)); + } + + + public function testCanCacheAVerifiedToken(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(null); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->maxCacheDurationMock->method('getInSeconds')->willReturn(21600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with('token', 600, self::URI); + + $this->sut()->cacheIt($this->statusListTokenMock, self::URI); + } + + + /** + * The ttl claim bounds how long a copy may be held, so it caps the cache duration alongside the expiration + * time and the configured maximum. + */ + public function testCachesForTheShortestOfTtlAndExpirationTime(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(time() + 3600); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->maxCacheDurationMock->method('lowestInSecondsComparedToExpirationTime')->willReturn(3600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with('token', 600, self::URI); + + $this->sut()->fromNetwork(self::URI); + } + + + public function testCachesForTheExpirationTimeWhenItIsShorterThanTtl(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(time() + 300); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(43200); + $this->maxCacheDurationMock->method('lowestInSecondsComparedToExpirationTime')->willReturn(300); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with('token', 300, self::URI); + + $this->sut()->fromNetwork(self::URI); + } + + + /** + * Without either claim the configured maximum is all there is to go on. + */ + public function testCachesForMaxCacheDurationWhenTokenStatesNeither(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(null); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(null); + $this->maxCacheDurationMock->method('getInSeconds')->willReturn(21600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with('token', 21600, self::URI); + + $this->sut()->fromNetwork(self::URI); + } + + + /** + * A fractional ttl is truncated rather than rounded up, so a copy is never held longer than stated. + */ + public function testTruncatesFractionalTtl(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(null); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(1.9); + $this->maxCacheDurationMock->method('getInSeconds')->willReturn(21600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with('token', 1, self::URI); + + $this->sut()->fromNetwork(self::URI); + } + + + /** + * A token that may not be held at all is not cached, rather than cached for zero seconds. + */ + public function testDoesNotCacheWhenNothingIsLeftOfTheCacheDuration(): void + { + $this->statusListTokenMock->method('getExpirationTime')->willReturn(time() - 1); + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->maxCacheDurationMock->method('lowestInSecondsComparedToExpirationTime')->willReturn(0); + + $this->artifactFetcherMock->expects($this->never())->method('cacheIt'); + + $this->sut()->fromNetwork(self::URI); + } + + + public function testDoesNotCacheWhenCachingIsNotAskedFor(): void + { + $this->artifactFetcherMock->expects($this->never())->method('cacheIt'); + + $this->sut()->fromNetwork(self::URI, shouldCache: false); + } +} diff --git a/tests/src/TokenStatusList/StatusListTokenTest.php b/tests/src/TokenStatusList/StatusListTokenTest.php new file mode 100644 index 0000000..db16aad --- /dev/null +++ b/tests/src/TokenStatusList/StatusListTokenTest.php @@ -0,0 +1,344 @@ + */ + protected array $sampleHeader; + + /** @var array */ + protected array $samplePayload; + + + protected function setUp(): void + { + $this->signatureMock = $this->createMock(Signature::class); + + $this->jwsMock = $this->createMock(JWS::class); + $this->jwsMock->method('getSignature')->willReturn($this->signatureMock); + + $this->jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $this->jwsDecoratorMock->method('jws')->willReturn($this->jwsMock); + + // Real helpers, so the type strictness the specification calls for is exercised rather than stubbed away. + $this->helpers = new Helpers(); + + $this->sampleHeader = [ + 'alg' => 'ES256', + 'kid' => '12', + 'typ' => 'statuslist+jwt', + ]; + + $this->samplePayload = [ + 'exp' => time() + 3600, + 'iat' => time() - 60, + 'status_list' => [ + 'bits' => 1, + 'lst' => self::EXAMPLE_LST, + ], + 'sub' => 'https://example.com/statuslists/1', + 'ttl' => 43200, + ]; + } + + + /** + * @param ?array $payload + * @param ?array $header + */ + protected function sut( + ?array $payload = null, + ?array $header = null, + ): StatusListToken { + $payload ??= $this->samplePayload; + $header ??= $this->sampleHeader; + + $this->jwsMock->method('getPayload')->willReturn(json_encode($payload)); + $this->signatureMock->method('getProtectedHeader')->willReturn($header); + + return new StatusListToken( + $this->jwsDecoratorMock, + $this->createStub(\SimpleSAML\OpenID\Jws\JwsVerifierDecorator::class), + $this->createStub(\SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory::class), + $this->createStub(\SimpleSAML\OpenID\Serializers\JwsSerializerManagerDecorator::class), + $this->createStub(\SimpleSAML\OpenID\Decorators\DateIntervalDecorator::class), + $this->helpers, + $this->createStub(\SimpleSAML\OpenID\Factories\ClaimFactory::class), + new StatusListFactory($this->helpers), + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusListToken::class, $this->sut()); + } + + + public function testCanGetClaims(): void + { + $sut = $this->sut(); + + $this->assertSame('statuslist+jwt', $sut->getType()); + $this->assertSame('ES256', $sut->getAlgorithm()); + $this->assertSame('12', $sut->getKeyId()); + $this->assertSame('https://example.com/statuslists/1', $sut->getSubject()); + $this->assertSame($this->samplePayload['iat'], $sut->getIssuedAt()); + $this->assertSame($this->samplePayload['exp'], $sut->getExpirationTime()); + $this->assertSame(43200, $sut->getTimeToLive()); + $this->assertSame(1, $sut->getBits()); + $this->assertSame(self::EXAMPLE_LST, $sut->getEncodedStatusList()); + $this->assertSame( + ['bits' => 1, 'lst' => self::EXAMPLE_LST], + $sut->getStatusListClaimData(), + ); + } + + + public function testCanGetStatusList(): void + { + $statusList = $this->sut()->getStatusList(1024); + + $this->assertSame(1, $statusList->getBits()); + $this->assertSame(16, $statusList->getCapacity()); + $this->assertSame(StatusTypeEnum::Invalid, $statusList->getStatusType(0)); + $this->assertSame(StatusTypeEnum::Valid, $statusList->getStatusType(1)); + } + + + /** + * The size bound is the caller's, and it is enforced when the list is decoded. + */ + public function testGetStatusListHonoursTheSizeBound(): void + { + $this->expectException(StatusListException::class); + + $this->sut()->getStatusList(1); + } + + + /** + * Both claims are only RECOMMENDED, so a token without them is valid. + */ + public function testAcceptsAbsentExpirationTimeAndTimeToLive(): void + { + $payload = $this->samplePayload; + unset($payload['exp'], $payload['ttl']); + + $sut = $this->sut($payload); + + $this->assertNull($sut->getExpirationTime()); + $this->assertNull($sut->getTimeToLive()); + } + + + /** + * The specification requires a positive number, not a whole one. + */ + public function testAcceptsNonIntegerTimeToLive(): void + { + $payload = $this->samplePayload; + $payload['ttl'] = 0.5; + + $this->assertEqualsWithDelta(0.5, $this->sut($payload)->getTimeToLive(), PHP_FLOAT_EPSILON); + } + + + /** + * A whole-second timestamp expressed as a JSON float is still a NumericDate. + */ + public function testAcceptsNumericDateAsFloat(): void + { + $payload = $this->samplePayload; + $payload['iat'] = (float)(time() - 60); + + $this->assertSame(time() - 60, $this->sut($payload)->getIssuedAt()); + } + + + /** + * RFC 7519 lets a NumericDate carry a fraction of a second, so one is accepted rather than rejected -- but + * the getters return whole seconds, so it is truncated towards zero on the way out. That keeps the subsecond + * part out of every decision, bounded under a second against a validation leeway of a minute by default, and + * makes `exp` expire a token marginally sooner rather than later. The claim exactly as signed remains + * readable through getPayloadClaim(). + */ + public function testTruncatesAFractionalNumericDateToWholeSeconds(): void + { + $issuedAt = time() - 60; + + $payload = $this->samplePayload; + $payload['iat'] = $issuedAt + 0.9; + + $sut = $this->sut($payload); + + $this->assertSame($issuedAt, $sut->getIssuedAt()); + $this->assertSame($issuedAt + 0.9, $sut->getPayloadClaim('iat')); + } + + + public function testAcceptsAdditionalClaims(): void + { + $payload = $this->samplePayload; + $payload['iss'] = 'https://example.com'; + + $this->assertSame('https://example.com', $this->sut($payload)->getIssuer()); + } + + + /** + * `crit` means the producer requires the listed parameters to be understood, and this token type defines no + * extensions for it to name, so the token is rejected rather than processed under rules never applied. + */ + public function testRejectsCriticalHeaderParametersByName(): void + { + $header = $this->sampleHeader; + $header['crit'] = ['http://example.com/UNDEFINED']; + + $this->expectException(JwsException::class); + $this->expectExceptionMessage("critical, and none are supported: 'http://example.com/UNDEFINED'"); + + $this->sut(null, $header); + } + + + /** + * @return \Iterator}> + */ + public static function invalidPayloadProvider(): \Iterator + { + $statusList = ['bits' => 1, 'lst' => self::EXAMPLE_LST]; + $base = [ + 'iat' => 1686920170, + 'status_list' => $statusList, + 'sub' => 'https://example.com/statuslists/1', + ]; + yield 'missing sub' => [array_diff_key($base, ['sub' => null])]; + yield 'non-URI sub' => [['sub' => 'statuslists/1'] + $base]; + yield 'missing iat' => [array_diff_key($base, ['iat' => null])]; + yield 'iat as a numeric string' => [['iat' => '1686920170'] + $base]; + yield 'iat as a boolean' => [['iat' => true] + $base]; + yield 'exp as a numeric string' => [['exp' => '2291720170'] + $base]; + yield 'missing status_list' => [array_diff_key($base, ['status_list' => null])]; + yield 'status_list not an object' => [['status_list' => 'nope'] + $base]; + yield 'missing bits' => [['status_list' => ['lst' => self::EXAMPLE_LST]] + $base]; + yield 'bits as a numeric string' => [ + ['status_list' => ['bits' => '1', 'lst' => self::EXAMPLE_LST]] + $base, + ]; + yield 'unsupported bits' => [ + ['status_list' => ['bits' => 3, 'lst' => self::EXAMPLE_LST]] + $base, + ]; + yield 'missing lst' => [['status_list' => ['bits' => 1]] + $base]; + yield 'zero ttl' => [['ttl' => 0] + $base]; + yield 'negative ttl' => [['ttl' => -1] + $base]; + yield 'ttl as a numeric string' => [['ttl' => '43200'] + $base]; + yield 'expired' => [['exp' => time() - 3600] + $base]; + // Casting a float this large to an integer yields 0 rather than saturating, so an unchecked value would + // read as the epoch and pass for a token issued long ago. + yield 'iat beyond the representable range' => [['iat' => 1e100] + $base]; + yield 'negative iat beyond the representable range' => [['iat' => -1e100] + $base]; + yield 'exp beyond the representable range' => [['exp' => 1e100] + $base]; + // Claims the specification does not ask a Status List Token to carry, but which have a defined shape + // once present. A token that is not a valid JWT is not one to read a status out of. Note that the + // library casts scalars to strings throughout, so `iss: 1` is a "1" rather than a rejected token. + yield 'iss as an array' => [['iss' => []] + $base]; + yield 'aud as a number' => [['aud' => 1] + $base]; + yield 'jti as an array' => [['jti' => ['a']] + $base]; + // Optional by being absent, not by being present and null: the inherited getters read a claim with `??` + // and so cannot tell the two apart on their own. + yield 'iss that is null' => [['iss' => null] + $base]; + yield 'aud that is null' => [['aud' => null] + $base]; + yield 'jti that is null' => [['jti' => null] + $base]; + } + + + /** + * @param array $payload + */ + #[DataProvider('invalidPayloadProvider')] + public function testThrowsForInvalidPayload(array $payload): void + { + $this->expectException(JwsException::class); + + $this->sut($payload); + } + + + /** + * @return \Iterator}> + */ + public static function invalidHeaderProvider(): \Iterator + { + yield 'missing typ' => [['alg' => 'ES256']]; + yield 'wrong typ' => [['alg' => 'ES256', 'typ' => 'JWT']]; + yield 'missing alg' => [['typ' => 'statuslist+jwt']]; + yield 'alg none' => [['alg' => 'none', 'typ' => 'statuslist+jwt']]; + yield 'unknown alg' => [['alg' => 'XX999', 'typ' => 'statuslist+jwt']]; + // RFC 7515 lets `crit` name only extensions, and this token type defines none, so anything listed there + // is something this code does not implement and was told it must. + yield 'crit naming an extension' => [ + ['alg' => 'ES256', 'crit' => ['http://example.com/UNDEFINED'], 'typ' => 'statuslist+jwt'], + ]; + yield 'crit that is empty' => [['alg' => 'ES256', 'crit' => [], 'typ' => 'statuslist+jwt']]; + yield 'crit that is not an array' => [['alg' => 'ES256', 'crit' => 'exp', 'typ' => 'statuslist+jwt']]; + // Present and null is a malformed declaration, not an omitted one, as RFC 7515 requires a `crit` that is + // there to be a non-empty array. + yield 'crit that is null' => [['alg' => 'ES256', 'crit' => null, 'typ' => 'statuslist+jwt']]; + // RFC 7515 has `kid` be a string. It is not required here, the specification mandating no key + // resolution method, but a malformed one still makes this an invalid JWS. + yield 'kid as an array' => [['alg' => 'ES256', 'kid' => ['12'], 'typ' => 'statuslist+jwt']]; + yield 'kid that is null' => [['alg' => 'ES256', 'kid' => null, 'typ' => 'statuslist+jwt']]; + } + + + /** + * @param array $header + */ + #[DataProvider('invalidHeaderProvider')] + public function testThrowsForInvalidHeader(array $header): void + { + $this->expectException(JwsException::class); + + $this->sut(null, $header); + } +} diff --git a/tests/src/TokenStatusList/StatusReferenceTest.php b/tests/src/TokenStatusList/StatusReferenceTest.php new file mode 100644 index 0000000..13d9457 --- /dev/null +++ b/tests/src/TokenStatusList/StatusReferenceTest.php @@ -0,0 +1,108 @@ +uri; + $idx ??= $this->idx; + + return new StatusReference($uri, $idx, new Helpers()); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusReference::class, $this->sut()); + } + + + public function testCanGetProperties(): void + { + $sut = $this->sut($this->uri, 42); + + $this->assertSame($this->uri, $sut->getUri()); + $this->assertSame(42, $sut->getIdx()); + } + + + public function testCanJsonSerialize(): void + { + $this->assertSame( + ['idx' => 0, 'uri' => 'https://example.com/statuslists/1'], + $this->sut()->jsonSerialize(), + ); + } + + + /** + * The URI is carried through untouched, since the Status List Token's `sub` claim has to equal it exactly. + */ + public function testUriIsNotNormalised(): void + { + $uri = 'https://example.com:443/statuslists/1/?a=b#c'; + + $this->assertSame($uri, $this->sut($uri)->getUri()); + } + + + public function testThrowsForNegativeIndex(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('non-negative integer'); + + $this->sut(null, -1); + } + + + /** + * @return \Iterator + */ + public static function invalidUriProvider(): \Iterator + { + yield 'empty' => ['']; + yield 'no scheme' => ['example.com/statuslists/1']; + yield 'plain text' => ['not a uri']; + } + + + #[DataProvider('invalidUriProvider')] + public function testThrowsForNonUri(string $uri): void + { + $this->expectException(InvalidValueException::class); + + $this->sut($uri); + } + + + /** + * The specification does not require an HTTPS URI, only one conforming to RFC 3986. + */ + public function testAcceptsNonHttpUri(): void + { + $this->assertSame('urn:example:statuslist:1', $this->sut('urn:example:statuslist:1')->getUri()); + } +} diff --git a/tests/src/TokenStatusList/StatusResolverTest.php b/tests/src/TokenStatusList/StatusResolverTest.php new file mode 100644 index 0000000..6ade3d9 --- /dev/null +++ b/tests/src/TokenStatusList/StatusResolverTest.php @@ -0,0 +1,451 @@ + [['kty' => 'EC', 'crv' => 'P-256', 'x' => 'x', 'y' => 'y']]]; + + protected MockObject $statusListTokenFetcherMock; + + protected MockObject $statusListTokenMock; + + protected StatusReference $statusReference; + + protected StatusList $statusList; + + + protected function setUp(): void + { + $helpers = new Helpers(); + + $this->statusListTokenFetcherMock = $this->createMock(StatusListTokenFetcher::class); + + $this->statusList = (new StatusListFactory($helpers))->fromEntries( + [ + 1 => StatusTypeEnum::Invalid, + 2 => StatusTypeEnum::Suspended, + 3 => 0b11, + ], + 2, + 1024, + ); + + $this->statusListTokenMock = $this->createMock(StatusListToken::class); + $this->statusListTokenMock->method('getSubject')->willReturn(self::URI); + $this->statusListTokenMock->method('getStatusList')->willReturn($this->statusList); + + $this->statusReference = new StatusReference(self::URI, 1, $helpers); + } + + + protected function sut(): StatusResolver + { + return new StatusResolver( + $this->statusListTokenFetcherMock, + $this->createStub(\Psr\Log\LoggerInterface::class), + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusResolver::class, $this->sut()); + } + + + public function testCanResolveWithToken(): void + { + $statusResult = $this->sut()->resolveWithToken( + $this->statusReference, + $this->statusListTokenMock, + $this->jwks, + 256, + ); + + $this->assertSame(StatusTypeEnum::Invalid, $statusResult->getStatusType()); + $this->assertSame(1, $statusResult->getStatus()); + $this->assertFalse($statusResult->isValid()); + $this->assertSame($this->statusReference, $statusResult->getStatusReference()); + $this->assertSame($this->statusListTokenMock, $statusResult->getStatusListToken()); + } + + + /** + * An application specific value is reported as the number it is, rather than being flattened into a Status + * Type the specification does not define for it. + */ + public function testResolvesApplicationSpecificStatus(): void + { + $statusResult = $this->sut()->resolveWithToken( + new StatusReference(self::URI, 3, new Helpers()), + $this->statusListTokenMock, + $this->jwks, + 256, + ); + + $this->assertSame(0b11, $statusResult->getStatus()); + $this->assertNotInstanceOf(\SimpleSAML\OpenID\Codebooks\StatusTypeEnum::class, $statusResult->getStatusType()); + $this->assertFalse($statusResult->isValid()); + } + + + public function testCanResolveByFetchingTheToken(): void + { + $this->statusListTokenFetcherMock->method('fromCache')->willReturn(null); + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('fromNetworkWithoutCaching') + ->with(self::URI, null) + ->willReturn($this->statusListTokenMock); + + $statusResult = $this->sut()->resolve($this->statusReference, $this->jwks, 256); + + $this->assertSame(StatusTypeEnum::Invalid, $statusResult->getStatusType()); + } + + + public function testResolvesFromTheCacheWithoutTouchingTheNetwork(): void + { + $this->statusListTokenFetcherMock->method('fromCache')->willReturn($this->statusListTokenMock); + $this->statusListTokenFetcherMock->expects($this->never())->method('fromNetworkWithoutCaching'); + + $statusResult = $this->sut()->resolve($this->statusReference, $this->jwks, 256); + + $this->assertSame(StatusTypeEnum::Invalid, $statusResult->getStatusType()); + } + + + /** + * A token is cached only once it has been verified and bound to the URI it was fetched from, so one bad + * response can not keep being served back for the whole cache period. + */ + public function testCachesTheTokenOnlyAfterItHasBeenVerified(): void + { + $this->statusListTokenFetcherMock->method('fromCache')->willReturn(null); + $this->statusListTokenFetcherMock->method('fromNetworkWithoutCaching') + ->willReturn($this->statusListTokenMock); + + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($this->statusListTokenMock, self::URI); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + public function testDoesNotCacheATokenThatFailsVerification(): void + { + $this->statusListTokenFetcherMock->method('fromCache')->willReturn(null); + $this->statusListTokenFetcherMock->method('fromNetworkWithoutCaching') + ->willReturn($this->statusListTokenMock); + $this->statusListTokenMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Could not verify JWS signature.')); + + $this->statusListTokenFetcherMock->expects($this->never())->method('cacheIt'); + + $this->expectException(JwsException::class); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + /** + * A token whose subject does not match is not cached either, even though it verified. + */ + public function testDoesNotCacheATokenWithAMismatchedSubject(): void + { + $statusListTokenMock = $this->createMock(StatusListToken::class); + $statusListTokenMock->method('getSubject')->willReturn('https://example.com/statuslists/2'); + + $this->statusListTokenFetcherMock->method('fromCache')->willReturn(null); + $this->statusListTokenFetcherMock->method('fromNetworkWithoutCaching')->willReturn($statusListTokenMock); + + $this->statusListTokenFetcherMock->expects($this->never())->method('cacheIt'); + + $this->expectException(StatusListTokenException::class); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + /** + * A token that came from the cache is not written straight back to it. + */ + public function testDoesNotRecacheATokenTakenFromTheCache(): void + { + $this->statusListTokenFetcherMock->method('fromCache')->willReturn($this->statusListTokenMock); + $this->statusListTokenFetcherMock->expects($this->never())->method('cacheIt'); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + /** + * A cached token that no longer resolves -- the Status Provider having rotated its keys since it was stored, + * say -- is worth no more than an empty cache, so it is treated as a miss rather than being the reason every + * Referenced Token pointing at this URI stays unresolvable until the entry expires. + */ + public function testRefetchesWhenTheCachedTokenNoLongerResolves(): void + { + $staleTokenMock = $this->createMock(StatusListToken::class); + $staleTokenMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Could not verify JWS signature.')); + + $this->statusListTokenFetcherMock->method('fromCache')->willReturn($staleTokenMock); + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('fromNetworkWithoutCaching') + ->with(self::URI, null) + ->willReturn($this->statusListTokenMock); + + $statusResult = $this->sut()->resolve($this->statusReference, $this->jwks, 256); + + $this->assertSame(StatusTypeEnum::Invalid, $statusResult->getStatusType()); + $this->assertSame($this->statusListTokenMock, $statusResult->getStatusListToken()); + } + + + /** + * Including when the cached entry cannot be parsed at all -- whatever sits under this URI failing to read is + * no more of an answer about the Referenced Token than a stale token is. + */ + public function testRefetchesWhenTheCachedEntryCannotBeParsed(): void + { + $this->statusListTokenFetcherMock->method('fromCache') + ->willThrowException(new JwsException('Unable to parse token.')); + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('fromNetworkWithoutCaching') + ->willReturn($this->statusListTokenMock); + + $statusResult = $this->sut()->resolve($this->statusReference, $this->jwks, 256); + + $this->assertSame(StatusTypeEnum::Invalid, $statusResult->getStatusType()); + } + + + /** + * The replacement is cached like any other verified token, which is also what displaces the unusable entry. + */ + public function testCachesTheTokenFetchedToReplaceAnUnusableCachedOne(): void + { + $staleTokenMock = $this->createMock(StatusListToken::class); + $staleTokenMock->method('getSubject')->willReturn('https://example.com/statuslists/2'); + + $this->statusListTokenFetcherMock->method('fromCache')->willReturn($staleTokenMock); + $this->statusListTokenFetcherMock->method('fromNetworkWithoutCaching') + ->willReturn($this->statusListTokenMock); + + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($this->statusListTokenMock, self::URI); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + /** + * One retry, not a loop: when the current representation fails the same way, that failure is the answer. + */ + public function testFetchesOnlyOnceWhenTheFreshTokenFailsAsWell(): void + { + $staleTokenMock = $this->createMock(StatusListToken::class); + $staleTokenMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Could not verify JWS signature.')); + + $freshTokenMock = $this->createMock(StatusListToken::class); + $freshTokenMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Could not verify JWS signature.')); + + $this->statusListTokenFetcherMock->method('fromCache')->willReturn($staleTokenMock); + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('fromNetworkWithoutCaching') + ->willReturn($freshTokenMock); + $this->statusListTokenFetcherMock->expects($this->never())->method('cacheIt'); + + $this->expectException(JwsException::class); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256); + } + + + public function testPassesTheDeadlineToTheFetcher(): void + { + $deadline = microtime(true) + 10; + + $this->statusListTokenFetcherMock->method('fromCache')->willReturn(null); + $this->statusListTokenFetcherMock->expects($this->once()) + ->method('fromNetworkWithoutCaching') + ->with(self::URI, $deadline) + ->willReturn($this->statusListTokenMock); + + $this->sut()->resolve($this->statusReference, $this->jwks, 256, null, $deadline); + } + + + /** + * The signature is checked before anything is read out of the token. + */ + public function testVerifiesTheSignature(): void + { + $this->statusListTokenMock->expects($this->once()) + ->method('verifyWithKeySet') + ->with($this->jwks); + + $this->sut()->resolveWithToken($this->statusReference, $this->statusListTokenMock, $this->jwks, 256); + } + + + public function testThrowsWhenSignatureDoesNotVerify(): void + { + $this->statusListTokenMock->method('verifyWithKeySet') + ->willThrowException(new JwsException('Could not verify JWS signature.')); + + $this->expectException(JwsException::class); + + $this->sut()->resolveWithToken($this->statusReference, $this->statusListTokenMock, $this->jwks, 256); + } + + + /** + * A token handed to resolveWithToken() may have been parsed long ago, so its expiration is checked again + * here rather than being trusted from construction time. + */ + public function testRechecksExpirationAtResolutionTime(): void + { + $this->statusListTokenMock->expects($this->once())->method('getExpirationTime'); + + $this->sut()->resolveWithToken($this->statusReference, $this->statusListTokenMock, $this->jwks, 256); + } + + + /** + * An expired Status List must not be what decides a Referenced Token's status. + */ + public function testThrowsForATokenThatHasExpiredSinceItWasParsed(): void + { + $this->statusListTokenMock->method('getExpirationTime') + ->willThrowException(new JwsException('Expiration Time claim (1) is lesser than current time.')); + + $this->expectException(JwsException::class); + + $this->sut()->resolveWithToken($this->statusReference, $this->statusListTokenMock, $this->jwks, 256); + } + + + /** + * A token whose subject is not the URI the Referenced Token pointed at says nothing about that token. + */ + public function testThrowsWhenSubjectDoesNotMatchTheReferencedUri(): void + { + $statusListTokenMock = $this->createMock(StatusListToken::class); + $statusListTokenMock->method('getSubject')->willReturn('https://example.com/statuslists/2'); + + $this->expectException(StatusListTokenException::class); + $this->expectExceptionMessage('subject does not match'); + + $this->sut()->resolveWithToken($this->statusReference, $statusListTokenMock, $this->jwks, 256); + } + + + /** + * Not even a trailing slash, since the two strings have to be equal. + */ + public function testThrowsWhenSubjectDiffersOnlyByATrailingSlash(): void + { + $statusListTokenMock = $this->createMock(StatusListToken::class); + $statusListTokenMock->method('getSubject')->willReturn(self::URI . '/'); + + $this->expectException(StatusListTokenException::class); + + $this->sut()->resolveWithToken($this->statusReference, $statusListTokenMock, $this->jwks, 256); + } + + + public function testThrowsForIndexOutOfBoundsOfTheList(): void + { + $this->expectException(StatusListException::class); + $this->expectExceptionMessage('out of bounds'); + + $this->sut()->resolveWithToken( + new StatusReference(self::URI, 1024, new Helpers()), + $this->statusListTokenMock, + $this->jwks, + 256, + ); + } + + + public function testUsesTheGivenResolutionTime(): void + { + $resolvedAt = new DateTimeImmutable('@1686920170'); + + $statusResult = $this->sut()->resolveWithToken( + $this->statusReference, + $this->statusListTokenMock, + $this->jwks, + 256, + null, + $resolvedAt, + ); + + $this->assertSame($resolvedAt, $statusResult->getResolvedAt()); + } + + + public function testDefaultsTheResolutionTimeToNow(): void + { + $statusResult = $this->sut()->resolveWithToken( + $this->statusReference, + $this->statusListTokenMock, + $this->jwks, + 256, + ); + + $this->assertEqualsWithDelta(time(), $statusResult->getResolvedAt()->getTimestamp(), 5); + } + + + /** + * The size bound the caller sets is handed on to the decoder rather than being decided here. + */ + public function testPassesTheSizeBoundsToTheToken(): void + { + $this->statusListTokenMock->expects($this->once()) + ->method('getStatusList') + ->with(256, 512) + ->willReturn($this->statusList); + + $this->sut()->resolveWithToken($this->statusReference, $this->statusListTokenMock, $this->jwks, 256, 512); + } +} diff --git a/tests/src/TokenStatusList/StatusResultTest.php b/tests/src/TokenStatusList/StatusResultTest.php new file mode 100644 index 0000000..df57664 --- /dev/null +++ b/tests/src/TokenStatusList/StatusResultTest.php @@ -0,0 +1,210 @@ +statusListTokenMock = $this->createMock(StatusListToken::class); + $this->statusReference = new StatusReference('https://example.com/statuslists/1', 0, new Helpers()); + $this->resolvedAt = new DateTimeImmutable('@1686920170'); + } + + + protected function sut(int $status = 0): StatusResult + { + return new StatusResult( + $status, + $this->statusReference, + $this->statusListTokenMock, + $this->resolvedAt, + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(StatusResult::class, $this->sut()); + } + + + public function testCanGetProperties(): void + { + $sut = $this->sut(1); + + $this->assertSame(1, $sut->getStatus()); + $this->assertSame(StatusTypeEnum::Invalid, $sut->getStatusType()); + $this->assertSame($this->statusReference, $sut->getStatusReference()); + $this->assertSame($this->statusListTokenMock, $sut->getStatusListToken()); + $this->assertSame($this->resolvedAt, $sut->getResolvedAt()); + } + + + public function testIsValidOnlyForTheValidStatus(): void + { + $this->assertTrue($this->sut(0)->isValid()); + $this->assertFalse($this->sut(1)->isValid()); + $this->assertFalse($this->sut(2)->isValid()); + // An application specific value is not a valid Referenced Token either. + $this->assertFalse($this->sut(3)->isValid()); + } + + + public function testStatusTypeIsNullForUnregisteredValue(): void + { + $this->assertNotInstanceOf(\SimpleSAML\OpenID\Codebooks\StatusTypeEnum::class, $this->sut(3)->getStatusType()); + $this->assertSame(3, $this->sut(3)->getStatus()); + } + + + public function testIsFreshWithinTheTimeToLive(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->statusListTokenMock->method('getPayloadClaim')->willReturn(null); + + $sut = $this->sut(); + + $this->assertTrue($sut->isFreshAt($this->resolvedAt)); + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+599 seconds'))); + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+600 seconds'))); + $this->assertFalse($sut->isFreshAt($this->resolvedAt->modify('+601 seconds'))); + } + + + /** + * The specification allows a fractional ttl, so the window it opens is measured with the subsecond part + * intact. Truncating both sides to whole seconds would leave a result looking fresh for most of a second + * after the issuer's window had closed. + */ + public function testMeasuresAFractionalTimeToLiveBelowTheSecond(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(0.5); + $this->statusListTokenMock->method('getPayloadClaim')->willReturn(null); + + $resolvedAt = new DateTimeImmutable('@1686920170.100000'); + + $sut = new StatusResult(0, $this->statusReference, $this->statusListTokenMock, $resolvedAt); + + // 0.4s in, so still inside the half second the issuer allowed. + $this->assertTrue($sut->isFreshAt(new DateTimeImmutable('@1686920170.500000'))); + // 0.8s in, and past it -- even though both instants truncate to the same whole second. + $this->assertFalse($sut->isFreshAt(new DateTimeImmutable('@1686920170.900000'))); + } + + + /** + * A token carrying neither claim states no limit, so the caller's own policy is all there is. + */ + public function testIsFreshWhenTokenStatesNeitherLimit(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(null); + $this->statusListTokenMock->method('getPayloadClaim')->willReturn(null); + + $this->assertTrue($this->sut()->isFreshAt($this->resolvedAt->modify('+10 years'))); + } + + + /** + * Past its expiration the Status List Token states nothing at all, so a result taken from it is not fresh + * however much of its ttl is left. + */ + public function testIsNotFreshPastTheTokenExpiration(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(86400); + $this->statusListTokenMock->method('getPayloadClaim') + ->willReturn($this->resolvedAt->getTimestamp() + 600); + + $sut = $this->sut(); + + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+599 seconds'))); + $this->assertFalse($sut->isFreshAt($this->resolvedAt->modify('+601 seconds'))); + } + + + /** + * RFC 7519 asks for the current time to be before the expiration time, so the expiration instant itself is + * already past it -- unlike the ttl window, whose final instant is still within what the issuer allowed. + */ + public function testTheExpirationInstantItselfIsNotFreshUnlikeTheTimeToLiveDeadline(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->statusListTokenMock->method('getPayloadClaim') + ->willReturn($this->resolvedAt->getTimestamp() + 600); + + $sut = $this->sut(); + + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+599 seconds'))); + // The ttl window has one second left at this point, so expiration is the only thing ending it. + $this->assertFalse($sut->isFreshAt($this->resolvedAt->modify('+600 seconds'))); + } + + + /** + * Expiration alone bounds a token that states no ttl. + */ + public function testExpirationBoundsFreshnessWithoutATimeToLive(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(null); + $this->statusListTokenMock->method('getPayloadClaim') + ->willReturn($this->resolvedAt->getTimestamp() + 300); + + $sut = $this->sut(); + + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+299 seconds'))); + $this->assertFalse($sut->isFreshAt($this->resolvedAt->modify('+300 seconds'))); + } + + + /** + * Whichever of the two limits falls first is the one that applies. + */ + public function testTimeToLiveBoundsFreshnessBeforeExpiration(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(60); + $this->statusListTokenMock->method('getPayloadClaim') + ->willReturn($this->resolvedAt->getTimestamp() + 86400); + + $sut = $this->sut(); + + $this->assertTrue($sut->isFreshAt($this->resolvedAt->modify('+60 seconds'))); + $this->assertFalse($sut->isFreshAt($this->resolvedAt->modify('+61 seconds'))); + } + + + /** + * Asking whether an expired result is fresh answers the question rather than throwing. + */ + public function testIsFreshAtDoesNotThrowForAnExpiredToken(): void + { + $this->statusListTokenMock->method('getTimeToLive')->willReturn(600); + $this->statusListTokenMock->method('getPayloadClaim') + ->willReturn($this->resolvedAt->getTimestamp() - 1); + + $this->assertFalse($this->sut()->isFreshAt($this->resolvedAt)); + } +} diff --git a/tests/src/TokenStatusListTest.php b/tests/src/TokenStatusListTest.php new file mode 100644 index 0000000..d65f889 --- /dev/null +++ b/tests/src/TokenStatusListTest.php @@ -0,0 +1,321 @@ +assertInstanceOf(TokenStatusList::class, $this->sut()); + } + + + public function testCanBuildTools(): void + { + $sut = $this->sut(); + + $this->assertInstanceOf(DateIntervalDecoratorFactory::class, $sut->dateIntervalDecoratorFactory()); + $this->assertInstanceOf(CacheDecoratorFactory::class, $sut->cacheDecoratorFactory()); + $this->assertInstanceOf(HttpClientDecoratorFactory::class, $sut->httpClientDecoratorFactory()); + $this->assertInstanceOf(DateIntervalDecorator::class, $sut->maxCacheDurationDecorator()); + $this->assertInstanceOf(DateIntervalDecorator::class, $sut->timestampValidationLeewayDecorator()); + $this->assertNotInstanceOf(\SimpleSAML\OpenID\Decorators\CacheDecorator::class, $sut->cacheDecorator()); + $this->assertInstanceOf(Helpers::class, $sut->helpers()); + $this->assertInstanceOf(ArtifactFetcher::class, $sut->artifactFetcher()); + $this->assertInstanceOf(ClaimFactory::class, $sut->claimFactory()); + $this->assertInstanceOf( + JwsSerializerManagerDecoratorFactory::class, + $sut->jwsSerializerManagerDecoratorFactory(), + ); + $this->assertInstanceOf(JwsSerializerManagerDecorator::class, $sut->jwsSerializerManagerDecorator()); + $this->assertInstanceOf(AlgorithmManagerDecoratorFactory::class, $sut->algorithmManagerDecoratorFactory()); + $this->assertInstanceOf(AlgorithmManagerDecorator::class, $sut->algorithmManagerDecorator()); + $this->assertInstanceOf(JwsDecoratorBuilderFactory::class, $sut->jwsDecoratorBuilderFactory()); + $this->assertInstanceOf(JwsDecoratorBuilder::class, $sut->jwsDecoratorBuilder()); + $this->assertInstanceOf(JwsVerifierDecoratorFactory::class, $sut->jwsVerifierDecoratorFactory()); + $this->assertInstanceOf(JwsVerifierDecorator::class, $sut->jwsVerifierDecorator()); + $this->assertInstanceOf(JwksDecoratorFactory::class, $sut->jwksDecoratorFactory()); + $this->assertInstanceOf(StatusListFactory::class, $sut->statusListFactory()); + $this->assertInstanceOf(StatusReferenceFactory::class, $sut->statusReferenceFactory()); + $this->assertInstanceOf(StatusListTokenFactory::class, $sut->statusListTokenFactory()); + $this->assertInstanceOf(StatusListTokenFetcher::class, $sut->statusListTokenFetcher()); + $this->assertInstanceOf(StatusResolver::class, $sut->statusResolver()); + } + + + public function testToolsAreMemoized(): void + { + $sut = $this->sut(); + + $this->assertSame($sut->statusListFactory(), $sut->statusListFactory()); + $this->assertSame($sut->statusListTokenFactory(), $sut->statusListTokenFactory()); + $this->assertSame($sut->statusResolver(), $sut->statusResolver()); + } + + + /** + * Issue a Status List Token, serialize it, parse it back, verify its signature and read a status out of it, + * the way an issuer and a relying party each would. + */ + public function testCanIssueAndResolveStatus(): void + { + $sut = $this->sut(); + $key = JWKFactory::createECKey('P-256'); + $signingKey = new JwkDecorator($key); + + $statusList = $sut->statusListFactory()->fromEntries( + [ + 1 => StatusTypeEnum::Invalid, + 2 => StatusTypeEnum::Suspended, + ], + 2, + 1024, + ); + + $issuedToken = $sut->statusListTokenFactory()->forStatusList( + $statusList, + self::URI, + $signingKey, + SignatureAlgorithmEnum::ES256, + new DateTimeImmutable(), + (new DateTimeImmutable())->add(new DateInterval('P7D')), + new DateInterval('PT12H'), + ); + + $token = $issuedToken->getToken(); + + // Relying party side, starting from the compact serialization it fetched. + $parsedToken = $sut->statusListTokenFactory()->fromToken($token); + + $this->assertSame('statuslist+jwt', $parsedToken->getType()); + $this->assertSame(self::URI, $parsedToken->getSubject()); + $this->assertSame(43200, $parsedToken->getTimeToLive()); + + $publicKeySet = ['keys' => [$key->toPublic()->jsonSerialize()]]; + + $expectedStatuses = [ + 0 => StatusTypeEnum::Valid, + 1 => StatusTypeEnum::Invalid, + 2 => StatusTypeEnum::Suspended, + ]; + + foreach ($expectedStatuses as $idx => $expected) { + $statusResult = $sut->statusResolver()->resolveWithToken( + $sut->statusReferenceFactory()->build(self::URI, $idx), + $parsedToken, + $publicKeySet, + 256, + ); + + $this->assertSame($expected, $statusResult->getStatusType()); + $this->assertSame($expected === StatusTypeEnum::Valid, $statusResult->isValid()); + } + } + + + /** + * A token whose subject is not the URI the credential pointed at is rejected, even though it is properly + * signed by the same issuer. + */ + public function testRejectsTokenWithMismatchedSubject(): void + { + $sut = $this->sut(); + $key = JWKFactory::createECKey('P-256'); + + $issuedToken = $sut->statusListTokenFactory()->forStatusList( + $sut->statusListFactory()->forCapacity(1024, 1), + 'https://example.com/statuslists/2', + new JwkDecorator($key), + SignatureAlgorithmEnum::ES256, + ); + + $this->expectException(StatusListTokenException::class); + $this->expectExceptionMessage('subject does not match'); + + $sut->statusResolver()->resolveWithToken( + $sut->statusReferenceFactory()->build(self::URI, 0), + $sut->statusListTokenFactory()->fromToken($issuedToken->getToken()), + ['keys' => [$key->toPublic()->jsonSerialize()]], + 128, + ); + } + + + /** + * A token signed by a key other than the one the relying party trusts is rejected. + */ + public function testRejectsTokenWithWrongSignature(): void + { + $sut = $this->sut(); + + $issuedToken = $sut->statusListTokenFactory()->forStatusList( + $sut->statusListFactory()->forCapacity(1024, 1), + self::URI, + new JwkDecorator(JWKFactory::createECKey('P-256')), + SignatureAlgorithmEnum::ES256, + ); + + $this->expectException(JwsException::class); + + $sut->statusResolver()->resolveWithToken( + $sut->statusReferenceFactory()->build(self::URI, 0), + $sut->statusListTokenFactory()->fromToken($issuedToken->getToken()), + ['keys' => [JWKFactory::createECKey('P-256')->toPublic()->jsonSerialize()]], + 128, + ); + } + + + /** + * An index beyond the end of the list yields no statement about the Referenced Token. + */ + public function testRejectsOutOfRangeIndex(): void + { + $sut = $this->sut(); + $key = JWKFactory::createECKey('P-256'); + + $issuedToken = $sut->statusListTokenFactory()->forStatusList( + $sut->statusListFactory()->forCapacity(1024, 1), + self::URI, + new JwkDecorator($key), + SignatureAlgorithmEnum::ES256, + ); + + $this->expectExceptionMessage('out of bounds'); + + $sut->statusResolver()->resolveWithToken( + $sut->statusReferenceFactory()->build(self::URI, 1024), + $sut->statusListTokenFactory()->fromToken($issuedToken->getToken()), + ['keys' => [$key->toPublic()->jsonSerialize()]], + 128, + ); + } + + + /** + * The claim an issuer puts into a Referenced Token round-trips back into the reference a relying party + * resolves with. + */ + public function testStatusClaimRoundTripsThroughAReferencedTokenPayload(): void + { + $sut = $this->sut(); + + $statusClaim = $sut->statusReferenceFactory()->buildClaim(self::URI, 42); + + $referencedTokenPayload = ['iss' => 'https://example.com'] + $statusClaim->jsonSerialize(); + + $statusReference = $sut->statusReferenceFactory()->fromReferencedTokenPayload($referencedTokenPayload); + + $this->assertInstanceOf(StatusReference::class, $statusReference); + $this->assertSame(self::URI, $statusReference->getUri()); + $this->assertSame(42, $statusReference->getIdx()); + } +} diff --git a/tools/extract-status-list-test-vectors.php b/tools/extract-status-list-test-vectors.php new file mode 100644 index 0000000..6590464 --- /dev/null +++ b/tools/extract-status-list-test-vectors.php @@ -0,0 +1,165 @@ + $line) { + if (preg_match('/^#{2,3} /', $line) !== 1) { + continue; + } + + // Any heading closes the section that was open. + if ($current !== null) { + $sections[$current]['end'] = $number; + $current = null; + } + + if (preg_match('/^### \[(C\.\d)\.\]/', $line, $matches) === 1) { + $current = $matches[1]; + $sections[$current] = ['start' => $number, 'end' => count($lines)]; + } +} + +if ($sections === []) { + throw new RuntimeException('No Appendix C sections found.'); +} + +$vectors = []; + +foreach ($sections as $name => $bounds) { + $body = array_slice($lines, $bounds['start'], $bounds['end'] - $bounds['start']); + + $statuses = []; + $bits = null; + $lst = null; + $collectingLst = false; + $lstParts = []; + + foreach ($body as $line) { + if (preg_match('/^\s*status\[(\d+)\]\s*=\s*0b([01]+)\s*$/', $line, $matches) === 1) { + $idx = (int)$matches[1]; + if (array_key_exists($idx, $statuses)) { + throw new RuntimeException(sprintf('%s: duplicate index %d in the spec text.', $name, $idx)); + } + $statuses[$idx] = bindec($matches[2]); + continue; + } + + if ($bits === null && preg_match('/^\s*"bits":\s*(\d+)\s*,?\s*$/', $line, $matches) === 1) { + $bits = (int)$matches[1]; + continue; + } + + // The "lst" value is wrapped across several indented lines; join until the closing quote. + if ($lst === null && !$collectingLst && preg_match('/^\s*"lst":\s*"(.*)$/', $line, $matches) === 1) { + $rest = $matches[1]; + if (str_ends_with($rest, '"')) { + $lst = substr($rest, 0, -1); + continue; + } + $collectingLst = true; + $lstParts[] = $rest; + continue; + } + + if ($collectingLst) { + $rest = trim($line); + if (str_ends_with($rest, '"')) { + $lstParts[] = substr($rest, 0, -1); + $lst = implode('', $lstParts); + $collectingLst = false; + continue; + } + $lstParts[] = $rest; + } + } + + if ($bits === null || $lst === null || $statuses === []) { + throw new RuntimeException(sprintf('%s: incomplete vector (bits/lst/statuses).', $name)); + } + + // Sanity-check the extraction by decoding the list ourselves and reading back every asserted index. + if (preg_match('/^[A-Za-z0-9_-]+$/', $lst) !== 1) { + throw new RuntimeException(sprintf('%s: lst is not unpadded base64url.', $name)); + } + + $padded = strtr($lst, '-_', '+/'); + $remainder = strlen($padded) % 4; + if ($remainder !== 0) { + $padded .= str_repeat('=', 4 - $remainder); + } + $compressed = base64_decode($padded, true); + if ($compressed === false) { + throw new RuntimeException(sprintf('%s: lst did not base64-decode.', $name)); + } + + $bytes = @gzuncompress($compressed); + if ($bytes === false) { + throw new RuntimeException(sprintf('%s: lst did not zlib-decompress.', $name)); + } + + $perByte = intdiv(8, $bits); + $mask = (1 << $bits) - 1; + + foreach ($statuses as $idx => $expected) { + $byteIndex = intdiv($idx, $perByte); + if ($byteIndex >= strlen($bytes)) { + throw new RuntimeException(sprintf('%s: index %d out of bounds.', $name, $idx)); + } + $shift = ($idx % $perByte) * $bits; + $actual = (ord($bytes[$byteIndex]) >> $shift) & $mask; + if ($actual !== $expected) { + throw new RuntimeException( + sprintf('%s: index %d decoded as %d, spec says %d.', $name, $idx, $actual, $expected), + ); + } + } + + ksort($statuses); + + $vectors[$name] = [ + 'bits' => $bits, + 'capacity' => 1 << 20, // Appendix C: "All examples are initialized with a size of 2^20 entries." + 'byteLength' => strlen($bytes), + 'statuses' => $statuses, + 'lst' => $lst, + ]; + + printf( + "%s: bits=%d, %d asserted indices, %d decompressed bytes, lst %d chars — all indices verified.%s", + $name, + $bits, + count($statuses), + strlen($bytes), + strlen($lst), + PHP_EOL, + ); +} + +$json = json_encode($vectors, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +file_put_contents($outPath, $json . PHP_EOL); + +printf('Wrote %s (%d bytes).%s', $outPath, strlen($json), PHP_EOL);