Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"ext-filter": "*",
"ext-mbstring": "*",
"ext-hash": "*",
"ext-zlib": "*",

"guzzlehttp/guzzle": "^7.8 || ^8.0",
"psr/http-client": "^1",
Expand Down
1 change: 1 addition & 0 deletions docs/1-openid.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
195 changes: 195 additions & 0 deletions docs/5-token-status-list.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions src/Codebooks/ClaimsEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand All @@ -443,6 +461,9 @@ enum ClaimsEnum: string

case TosUri = 'tos_uri';

// Time To Live
case Ttl = 'ttl';

// Type
case Typ = 'typ';

Expand Down
2 changes: 2 additions & 0 deletions src/Codebooks/ContentTypesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/Codebooks/HttpHeadersEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

enum HttpHeadersEnum: string
{
case Accept = 'Accept';

case ContentType = 'Content-Type';

case AccessControlAllowOrigin = 'Access-Control-Allow-Origin';
Expand Down
2 changes: 2 additions & 0 deletions src/Codebooks/JwtTypesEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
46 changes: 46 additions & 0 deletions src/Codebooks/StatusTypeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Codebooks;

/**
* Status Type values for a Token Status List entry.
*
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-status-list#name-status-types-values
*
* Value 0x03 and values 0x0C to 0x0F are permanently reserved as application specific, so they are deliberately
* absent here; all other unlisted values are reserved for future registration in the Status Types registry.
*
* Note that the processing rules for the Referenced Token supersede its status in the list: a token evaluated as
* expired through its own claims stays expired even when the list reports it as Valid.
*
* @see \SimpleSAML\Test\OpenID\Codebooks\StatusTypeEnumTest
*/
enum StatusTypeEnum: int
{
/** The status of the Referenced Token is valid, correct or legal. */
case Valid = 0x00;

/** The status of the Referenced Token is revoked, annulled, taken back, recalled or cancelled. */
case Invalid = 0x01;

/**
* The status of the Referenced Token is temporarily invalid, hanging, debarred from privilege. This status
* is usually temporary.
*/
case Suspended = 0x02;


/**
* The smallest number of bits per entry a Status List needs in order to be able to represent this status.
* A list configured with fewer bits than this can never carry the status.
*/
public function requiredBits(): int
{
return match ($this) {
self::Valid, self::Invalid => 1,
self::Suspended => 2,
};
}
}
9 changes: 9 additions & 0 deletions src/Exceptions/StatusListException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Exceptions;

class StatusListException extends OpenIdException
{
}
9 changes: 9 additions & 0 deletions src/Exceptions/StatusListTokenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace SimpleSAML\OpenID\Exceptions;

class StatusListTokenException extends JwsException
{
}
Loading
Loading