From 44e43cf225a738baabeb81c47bfcf84d748c296d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:20:49 +0530 Subject: [PATCH 1/8] feat(auth): add JWT verifiers, claim/header enums, and docs split Add a generic JWS verifier layer mirroring the existing Issuers tree: Verifier (base) owns compact-JWS decode, the alg-confusion guard, and standard exp/nbf/iat/iss/aud claim validation with clock-skew leeway; Verifiers\Asymmetric (RS256) and Verifiers\Symmetric (HS256) delegate only the signature check. Stays dependency-free (native openssl/hash). Introduce Enums\Claim (RFC 7519 + OAuth2/OIDC claim names) and Enums\Header (RFC 7515 JOSE params) and thread them through both the verifier and the issuers, replacing scattered string literals. Enums and string-keyed array spread require PHP 8.1, so bump the constraint to >=8.1. Split the oversized README into a lean overview plus a docs/ folder (hashing, proofs, store, jwt) and replace the dead Travis badge with the monorepo GitHub Actions badge. --- packages/auth/README.md | 280 ++---------------- packages/auth/composer.json | 2 +- packages/auth/composer.lock | 4 +- packages/auth/docs/hashing.md | 67 +++++ packages/auth/docs/jwt.md | 168 +++++++++++ packages/auth/docs/proofs.md | 59 ++++ packages/auth/docs/store.md | 33 +++ packages/auth/src/Auth/Enums/Claim.php | 52 ++++ packages/auth/src/Auth/Enums/Header.php | 18 ++ packages/auth/src/Auth/Issuer.php | 11 +- packages/auth/src/Auth/Issuers/Asymmetric.php | 3 +- .../Auth/Issuers/Asymmetric/AccessToken.php | 26 +- .../src/Auth/Issuers/Asymmetric/IdToken.php | 26 +- packages/auth/src/Auth/Issuers/Symmetric.php | 3 +- .../Auth/Issuers/Symmetric/RefreshToken.php | 24 +- packages/auth/src/Auth/Verifier.php | 259 ++++++++++++++++ .../auth/src/Auth/Verifiers/Asymmetric.php | 89 ++++++ .../auth/src/Auth/Verifiers/Symmetric.php | 47 +++ .../Auth/Verifiers/VerificationException.php | 10 + .../tests/Auth/Verifiers/AsymmetricTest.php | 153 ++++++++++ .../tests/Auth/Verifiers/SymmetricTest.php | 75 +++++ 21 files changed, 1103 insertions(+), 306 deletions(-) create mode 100644 packages/auth/docs/hashing.md create mode 100644 packages/auth/docs/jwt.md create mode 100644 packages/auth/docs/proofs.md create mode 100644 packages/auth/docs/store.md create mode 100644 packages/auth/src/Auth/Enums/Claim.php create mode 100644 packages/auth/src/Auth/Enums/Header.php create mode 100644 packages/auth/src/Auth/Verifier.php create mode 100644 packages/auth/src/Auth/Verifiers/Asymmetric.php create mode 100644 packages/auth/src/Auth/Verifiers/Symmetric.php create mode 100644 packages/auth/src/Auth/Verifiers/VerificationException.php create mode 100644 packages/auth/tests/Auth/Verifiers/AsymmetricTest.php create mode 100644 packages/auth/tests/Auth/Verifiers/SymmetricTest.php diff --git a/packages/auth/README.md b/packages/auth/README.md index fb70a9ca..e9bef618 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -3,289 +3,51 @@ > [!IMPORTANT] > This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/auth`](https://github.com/utopia-php/monorepo/tree/main/packages/auth) — please open issues and pull requests there. -[![Build Status](https://travis-ci.org/utopia-php/auth.svg?branch=master)](https://travis-ci.org/utopia-php/auth) +[![Tests](https://github.com/utopia-php/monorepo/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/monorepo/actions/workflows/tests.yml) ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/auth.svg) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) -Utopia Auth library is a simple and lite library for handling authentication and authorization in PHP applications. This library provides a collection of secure hashing algorithms and authentication proofs for building robust authentication systems. This library is maintained by the [Appwrite team](https://appwrite.io). +Utopia Auth is a simple, dependency-free PHP library for building authentication and authorization: secure password hashing, authentication proofs (tokens, codes, phrases), and signing/verifying OAuth2 and OpenID Connect JWTs. It is maintained by the [Appwrite team](https://appwrite.io). -Although this library is part of the [Utopia Framework](https://github.com/utopia-php/framework) project it is dependency free and can be used as standalone with any other PHP project or framework. +Although it is part of the [Utopia Framework](https://github.com/utopia-php/framework) project, it is dependency free and can be used standalone with any PHP project or framework. ## Getting Started Install using composer: + ```bash composer require utopia-php/auth ``` -## System Requirements - -Utopia Framework requires PHP 8.0 or later. We recommend using the latest PHP version whenever possible. - -## Features - -### Supported Hashing Hashes - -- **Argon2** - Modern, secure, and recommended password hashing algorithm -- **Bcrypt** - Well-established and secure password hashing -- **Scrypt** - Memory-hard password hashing algorithm -- **ScryptModified** - Modified version of Scrypt with additional features -- **SHA** - Various SHA hash implementations -- **PHPass** - Portable password hashing framework -- **MD5** (Not recommended for passwords, legacy support only) - -### Token Issuers - -A generic framework for minting signed [JWS](https://datatracker.ietf.org/doc/html/rfc7515) tokens. The base `Issuer` is **not** tied to any particular protocol — it owns the JWS mechanics (header assembly, `jti` generation, base64url encoding and the header/payload/signature structure) and delegates only the signing algorithm and claim set to a subclass. - -## Usage - -### Data Store - -```php -set('userId', '12345') - ->set('name', 'John Doe') - ->set('isActive', true) - ->set('preferences', ['theme' => 'dark', 'notifications' => true]); - -// Get values with optional defaults -$userId = $store->get('userId'); -$missing = $store->get('missing', 'default value'); - -// Encode store data to a base64 string -$encoded = $store->encode(); - -// Later, decode the string back into a store -$newStore = new Store(); -$newStore->decode($encoded); - -// Access the decoded data -echo $newStore->get('name'); // Outputs: John Doe -``` - -### Password Hashing - ```php hash('user-password'); - -// Verify the password $isValid = $password->verify('user-password', $hash); - -// Use a specific algorithm with custom parameters -$bcrypt = new Bcrypt(); -$bcrypt->setCost(12); // Increase cost factor for better security - -$password->setHash($bcrypt); -$hash = $password->hash('user-password'); ``` -### Authentication Tokens - -```php -generate(); // Random token -$hashedToken = $token->hash($authToken); // Store this in database - -// Later, verify the token -$isValid = $token->verify($authToken, $hashedToken); -``` - -### One-Time Codes - -```php -generate(); -$hashedCode = $code->hash($verificationCode); - -// Verify the code -$isValid = $code->verify($verificationCode, $hashedCode); -``` - -### Human-Readable Phrases - -```php -generate(); // e.g., "Brave cat" -$hashedPhrase = $phrase->hash($authPhrase); - -// Verify the phrase -$isValid = $phrase->verify($authPhrase, $hashedPhrase); -``` - -### Advanced Hash Configuration - -```php -setCpuCost(16) // CPU/Memory cost parameter - ->setMemoryCost(14) // Memory cost parameter - ->setParallelCost(2) // Parallelization parameter - ->setLength(64) // Output length in bytes - ->setSalt('randomsalt123'); // Custom salt - -// Configure Argon2 parameters -$argon2 = new Argon2(); -$argon2 - ->setMemoryCost(65536) // Memory cost in KiB - ->setTimeCost(4) // Number of iterations - ->setThreads(3); // Number of threads -``` - -### Issuing Tokens - -#### OAuth2 Access Tokens (RFC 9068) - -```php -issue( - subject: 'user-123', // "sub" — the resource owner - audience: ['https://api.example.com'], // "aud" — the resource server - clientId: 'client-abc', // "client_id" — the client it was issued to - authTime: time(), // "auth_time" — when the user authenticated - duration: 3600, // Lifetime in seconds ("exp") - scopes: ['openid', 'profile', 'email'] -); - -$jwt = $accessToken->issue( - subject: 'user-123', - audience: ['https://api.example.com', 'https://mcp.example.com'], - clientId: 'client-abc', - authTime: time(), - duration: 3600, - scopes: ['openid', 'profile'] -); - -// Publish the public key as a JWK so resource servers can verify tokens -$jwk = $accessToken->getPublicJwk(); -$keyId = $accessToken->getKeyId(); -``` - -#### OAuth2 Refresh Tokens (HS256) - -```php -issue( - subject: 'user-123', // "sub" - audience: 'https://example.com/v1/oauth2/token', // "aud" — the token endpoint - clientId: 'client-abc', // "client_id" - duration: 1209600, // Lifetime in seconds (e.g. 14 days) - scopes: ['openid', 'profile'] -); -``` - -#### ID Tokens (OpenID Connect) - -```php -issue( - subject: 'user-123', // "sub" — the authenticated user - audience: 'client-abc', // "aud" — the client the token is for - authTime: time(), // "auth_time" - duration: 3600, // Lifetime in seconds ("exp") - nonce: 'n-0S6_WzA2Mj', // Optional "nonce" from the auth request - accessToken: $jwt, // Optional co-issued access_token (adds "at_hash") - code: null // Optional co-issued authorization code (adds "c_hash") -); -``` - -> Both asymmetric and symmetric issuers accept an optional `keyId` constructor argument (the JWS `kid` header) for key rotation. For asymmetric issuers it is derived deterministically from the public key when omitted. +## System Requirements -#### OAuth2 Resource Indicators (RFC 8707) +Utopia Auth requires PHP 8.1 or later. We recommend using the latest PHP version whenever possible. -```php -isSubsetOf($previouslyGrantedResources); -$unchanged = $resources->equals($previouslyGrantedResources); -$audience = $resources->audience('https://cloud.example.com/v1/project'); -$serialized = $resources->toArray(); -``` +- [Password Hashing](docs/hashing.md) — algorithms and tuning +- [Authentication Proofs](docs/proofs.md) — tokens, one-time codes, and phrases +- [Data Store](docs/store.md) — encode/decode authentication state +- [JSON Web Tokens](docs/jwt.md) — issuing and verifying OAuth2 / OpenID Connect tokens ## Tests @@ -295,12 +57,6 @@ To run all unit tests, use the following Docker command: docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests ``` -To run static code analysis, use the following command: - -```bash -docker compose exec tests composer check -``` - ## Security We take security seriously. If you discover any security-related issues, please email security@appwrite.io instead of using the issue tracker. diff --git a/packages/auth/composer.json b/packages/auth/composer.json index 35f9fa35..29aba30d 100644 --- a/packages/auth/composer.json +++ b/packages/auth/composer.json @@ -30,7 +30,7 @@ "test": "phpunit --configuration phpunit.xml" }, "require": { - "php": ">=8.0", + "php": ">=8.1", "ext-hash": "*", "ext-openssl": "*", "ext-scrypt": "*", diff --git a/packages/auth/composer.lock b/packages/auth/composer.lock index 74df09a9..c08a5e60 100644 --- a/packages/auth/composer.lock +++ b/packages/auth/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8b579a0ba2e499f4d1881cdb66476b18", + "content-hash": "269c143b4eb9b148bc36a1158d3af5b3", "packages": [], "packages-dev": [], "aliases": [], @@ -13,7 +13,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0", + "php": ">=8.1", "ext-hash": "*", "ext-openssl": "*", "ext-scrypt": "*", diff --git a/packages/auth/docs/hashing.md b/packages/auth/docs/hashing.md new file mode 100644 index 00000000..687316b4 --- /dev/null +++ b/packages/auth/docs/hashing.md @@ -0,0 +1,67 @@ +# Password Hashing + +The `Password` proof hashes and verifies passwords using a pluggable hashing +algorithm. Argon2 is used by default; any of the bundled hashes can be swapped +in. + +```php +hash('user-password'); + +// Verify the password +$isValid = $password->verify('user-password', $hash); + +// Use a specific algorithm with custom parameters +$bcrypt = new Bcrypt(); +$bcrypt->setCost(12); // Increase cost factor for better security + +$password->setHash($bcrypt); +$hash = $password->hash('user-password'); +``` + +## Supported algorithms + +- **Argon2** — modern, secure, and the recommended password hashing algorithm +- **Bcrypt** — well-established and secure password hashing +- **Scrypt** — memory-hard password hashing algorithm +- **ScryptModified** — modified version of Scrypt with additional features +- **SHA** — various SHA hash implementations +- **PHPass** — portable password hashing framework +- **MD5** — not recommended for passwords, legacy support only + +## Advanced hash configuration + +Each hash exposes a fluent API for tuning its cost parameters. + +```php +setCpuCost(16) // CPU/Memory cost parameter + ->setMemoryCost(14) // Memory cost parameter + ->setParallelCost(2) // Parallelization parameter + ->setLength(64) // Output length in bytes + ->setSalt('randomsalt123'); // Custom salt + +// Configure Argon2 parameters +$argon2 = new Argon2(); +$argon2 + ->setMemoryCost(65536) // Memory cost in KiB + ->setTimeCost(4) // Number of iterations + ->setThreads(3); // Number of threads +``` diff --git a/packages/auth/docs/jwt.md b/packages/auth/docs/jwt.md new file mode 100644 index 00000000..0954e766 --- /dev/null +++ b/packages/auth/docs/jwt.md @@ -0,0 +1,168 @@ +# JSON Web Tokens + +The library mints and verifies signed [JWS](https://datatracker.ietf.org/doc/html/rfc7515) +tokens for OAuth2 and OpenID Connect. Issuers and verifiers share a common base +that owns the JWS mechanics and delegates only the signing algorithm: + +- **Issuers** — `Issuer` owns header assembly, `jti` generation, base64url + encoding and the header/payload/signature structure. +- **Verifiers** — `Verifier` owns splitting the compact form, base64url/JSON + decoding, the `alg` guard and the standard `exp`/`nbf`/`iat`/`iss`/`aud` + claim checks. + +The two signing families are `Asymmetric` (RS256, RSA keypair) and `Symmetric` +(HS256, shared secret). + +## Issuing tokens + +### OAuth2 access tokens (RFC 9068) + +```php +issue( + subject: 'user-123', // "sub" — the resource owner + audience: ['https://api.example.com'], // "aud" — the resource server + clientId: 'client-abc', // "client_id" — the client it was issued to + authTime: time(), // "auth_time" — when the user authenticated + duration: 3600, // Lifetime in seconds ("exp") + scopes: ['openid', 'profile', 'email'] +); + +// Publish the public key as a JWK so resource servers can verify tokens +$jwk = $accessToken->getPublicJwk(); +$keyId = $accessToken->getKeyId(); +``` + +### OAuth2 refresh tokens (HS256) + +```php +issue( + subject: 'user-123', // "sub" + audience: 'https://example.com/v1/oauth2/token', // "aud" — the token endpoint + clientId: 'client-abc', // "client_id" + duration: 1209600, // Lifetime in seconds (e.g. 14 days) + scopes: ['openid', 'profile'] +); +``` + +### ID tokens (OpenID Connect) + +```php +issue( + subject: 'user-123', // "sub" — the authenticated user + audience: 'client-abc', // "aud" — the client the token is for + authTime: time(), // "auth_time" + duration: 3600, // Lifetime in seconds ("exp") + nonce: 'n-0S6_WzA2Mj', // Optional "nonce" from the auth request + accessToken: null, // Optional co-issued access_token (adds "at_hash") + code: null // Optional co-issued authorization code (adds "c_hash") +); +``` + +> Both asymmetric and symmetric issuers accept an optional `keyId` constructor argument (the JWS `kid` header) for key rotation. For asymmetric issuers it is derived deterministically from the public key when omitted. + +### OAuth2 resource indicators (RFC 8707) + +```php +isSubsetOf($previouslyGrantedResources); +$unchanged = $resources->equals($previouslyGrantedResources); +$audience = $resources->audience('https://cloud.example.com/v1/project'); +$serialized = $resources->toArray(); +``` + +## Verifying tokens + +Verify a token minted by one of the issuers (or any compliant JWS). The +signature is checked first, then the `alg` header, then whatever claim +expectations you configure. `verify()` returns the decoded claims or throws a +`VerificationException`. + +```php +setIssuer('https://example.com/v1/oauth2/project') + ->setAudience('https://example.com/v1/project') + ->setLeeway(30); // tolerate 30s of clock skew + +try { + $claims = $verifier->verify($accessToken); +} catch (VerificationException) { + // malformed, bad signature, wrong alg, expired, or a claim mismatch +} +``` + +For an OpenID Connect `id_token_hint` (which must be accepted even after it +expires), relax the time checks with `allowExpired()`: + +```php +$claims = (new Asymmetric($publicKey)) + ->setIssuer($issuer) + ->allowExpired() + ->verify($idToken); +``` + +HS256 tokens (e.g. refresh tokens) are verified the same way with the shared +secret: + +```php +use Utopia\Auth\Verifiers\Symmetric; + +$claims = (new Symmetric($secret)) + ->setIssuer($issuer) + ->setAudience('https://example.com/token') + ->verify($refreshToken); +``` diff --git a/packages/auth/docs/proofs.md b/packages/auth/docs/proofs.md new file mode 100644 index 00000000..3958eb88 --- /dev/null +++ b/packages/auth/docs/proofs.md @@ -0,0 +1,59 @@ +# Authentication Proofs + +Proofs are secrets you generate, hand to a user, and later verify against a +stored hash. Each proof generates a value and hashes/verifies it through the +underlying `Hash` (Argon2 by default). + +## Authentication tokens + +Cryptographically secure random tokens, suitable for session or API tokens. + +```php +generate(); // Random token +$hashedToken = $token->hash($authToken); // Store this in database + +// Later, verify the token +$isValid = $token->verify($authToken, $hashedToken); +``` + +## One-time codes + +Numeric codes, e.g. for two-factor authentication or email/phone verification. + +```php +generate(); +$hashedCode = $code->hash($verificationCode); + +// Verify the code +$isValid = $code->verify($verificationCode, $hashedCode); +``` + +## Human-readable phrases + +Memorable phrases, useful as a recognizable confirmation value. + +```php +generate(); // e.g., "Brave cat" +$hashedPhrase = $phrase->hash($authPhrase); + +// Verify the phrase +$isValid = $phrase->verify($authPhrase, $hashedPhrase); +``` diff --git a/packages/auth/docs/store.md b/packages/auth/docs/store.md new file mode 100644 index 00000000..e85acd8b --- /dev/null +++ b/packages/auth/docs/store.md @@ -0,0 +1,33 @@ +# Data Store + +`Store` is a simple key/value container that can be base64-encoded to a string +and decoded back — useful for serializing authentication state. + +```php +set('userId', '12345') + ->set('name', 'John Doe') + ->set('isActive', true) + ->set('preferences', ['theme' => 'dark', 'notifications' => true]); + +// Get values with optional defaults +$userId = $store->get('userId'); +$missing = $store->get('missing', 'default value'); + +// Encode store data to a base64 string +$encoded = $store->encode(); + +// Later, decode the string back into a store +$newStore = new Store(); +$newStore->decode($encoded); + +// Access the decoded data +echo $newStore->get('name'); // Outputs: John Doe +``` diff --git a/packages/auth/src/Auth/Enums/Claim.php b/packages/auth/src/Auth/Enums/Claim.php new file mode 100644 index 00000000..e6a38d02 --- /dev/null +++ b/packages/auth/src/Auth/Enums/Claim.php @@ -0,0 +1,52 @@ + $this->getType(), - 'alg' => $this->getAlgorithm(), - ], $this->getHeaders()); + $header = [ + Header::Type->value => $this->getType(), + Header::Algorithm->value => $this->getAlgorithm(), + ...$this->getHeaders(), + ]; $signingInput = $this->base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)) . '.' diff --git a/packages/auth/src/Auth/Issuers/Asymmetric.php b/packages/auth/src/Auth/Issuers/Asymmetric.php index a90afc0c..1716d032 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric.php @@ -2,6 +2,7 @@ namespace Utopia\Auth\Issuers; +use Utopia\Auth\Enums\Header; use Utopia\Auth\Issuer; /** @@ -168,7 +169,7 @@ protected function getAlgorithm(): string */ protected function getHeaders(): array { - return ['kid' => $this->getKeyId()]; + return [Header::KeyId->value => $this->getKeyId()]; } /** diff --git a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php index 0c053bdc..f688bc48 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -2,6 +2,7 @@ namespace Utopia\Auth\Issuers\Asymmetric; +use Utopia\Auth\Enums\Claim; use Utopia\Auth\Issuers\Asymmetric; /** @@ -72,21 +73,22 @@ public function issue( // "scope" is issuer-controlled; drop any caller-supplied value so it // cannot be injected through $claims when $scopes is empty. - unset($claims['scope']); + unset($claims[Claim::Scope->value]); - $claims = array_merge($claims, [ - 'iss' => $this->issuer, - 'aud' => $audience, - 'sub' => $subject, - 'client_id' => $clientId, - 'exp' => $now + $duration, - 'iat' => $now, - 'jti' => $jti ?? $this->generateJti(), - 'auth_time' => $authTime, - ]); + $claims = [ + ...$claims, + Claim::Issuer->value => $this->issuer, + Claim::Audience->value => $audience, + Claim::Subject->value => $subject, + Claim::ClientId->value => $clientId, + Claim::Expiration->value => $now + $duration, + Claim::IssuedAt->value => $now, + Claim::JwtId->value => $jti ?? $this->generateJti(), + Claim::AuthTime->value => $authTime, + ]; if (!empty($scopes)) { - $claims['scope'] = implode(' ', $scopes); + $claims[Claim::Scope->value] = implode(' ', $scopes); } return $this->sign($claims); diff --git a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php b/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php index 04ec25ae..de15e0c6 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php @@ -2,6 +2,7 @@ namespace Utopia\Auth\Issuers\Asymmetric; +use Utopia\Auth\Enums\Claim; use Utopia\Auth\Issuers\Asymmetric; /** @@ -58,27 +59,28 @@ public function issue( // values so they cannot be injected through $claims when the matching // parameter is absent (e.g. a forged at_hash binding the id_token to an // access token that was never co-issued). - unset($claims['nonce'], $claims['at_hash'], $claims['c_hash']); + unset($claims[Claim::Nonce->value], $claims[Claim::AccessTokenHash->value], $claims[Claim::CodeHash->value]); - $claims = array_merge($claims, [ - 'iss' => $this->issuer, - 'sub' => $subject, - 'aud' => $audience, - 'exp' => $now + $duration, - 'iat' => $now, - 'auth_time' => $authTime, - ]); + $claims = [ + ...$claims, + Claim::Issuer->value => $this->issuer, + Claim::Subject->value => $subject, + Claim::Audience->value => $audience, + Claim::Expiration->value => $now + $duration, + Claim::IssuedAt->value => $now, + Claim::AuthTime->value => $authTime, + ]; if (!empty($nonce)) { - $claims['nonce'] = $nonce; + $claims[Claim::Nonce->value] = $nonce; } if (!empty($accessToken)) { - $claims['at_hash'] = $this->leftHalfHash($accessToken); + $claims[Claim::AccessTokenHash->value] = $this->leftHalfHash($accessToken); } if (!empty($code)) { - $claims['c_hash'] = $this->leftHalfHash($code); + $claims[Claim::CodeHash->value] = $this->leftHalfHash($code); } return $this->sign($claims); diff --git a/packages/auth/src/Auth/Issuers/Symmetric.php b/packages/auth/src/Auth/Issuers/Symmetric.php index 6cbb5c0d..f0ccd8a6 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric.php +++ b/packages/auth/src/Auth/Issuers/Symmetric.php @@ -2,6 +2,7 @@ namespace Utopia\Auth\Issuers; +use Utopia\Auth\Enums\Header; use Utopia\Auth\Issuer; /** @@ -77,7 +78,7 @@ protected function getAlgorithm(): string */ protected function getHeaders(): array { - return $this->keyId !== null ? ['kid' => $this->keyId] : []; + return $this->keyId !== null ? [Header::KeyId->value => $this->keyId] : []; } protected function signInput(string $signingInput): string diff --git a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php index a934754f..5f280e66 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php +++ b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -2,6 +2,7 @@ namespace Utopia\Auth\Issuers\Symmetric; +use Utopia\Auth\Enums\Claim; use Utopia\Auth\Issuers\Symmetric; /** @@ -53,20 +54,21 @@ public function issue( // "scope" is issuer-controlled; drop any caller-supplied value so it // cannot be injected through $claims when $scopes is empty. - unset($claims['scope']); + unset($claims[Claim::Scope->value]); - $claims = array_merge($claims, [ - 'iss' => $this->issuer, - 'aud' => $audience, - 'sub' => $subject, - 'client_id' => $clientId, - 'exp' => $now + $duration, - 'iat' => $now, - 'jti' => $jti ?? $this->generateJti(), - ]); + $claims = [ + ...$claims, + Claim::Issuer->value => $this->issuer, + Claim::Audience->value => $audience, + Claim::Subject->value => $subject, + Claim::ClientId->value => $clientId, + Claim::Expiration->value => $now + $duration, + Claim::IssuedAt->value => $now, + Claim::JwtId->value => $jti ?? $this->generateJti(), + ]; if (!empty($scopes)) { - $claims['scope'] = implode(' ', $scopes); + $claims[Claim::Scope->value] = implode(' ', $scopes); } return $this->sign($claims); diff --git a/packages/auth/src/Auth/Verifier.php b/packages/auth/src/Auth/Verifier.php new file mode 100644 index 00000000..0032117c --- /dev/null +++ b/packages/auth/src/Auth/Verifier.php @@ -0,0 +1,259 @@ +|null + */ + protected ?array $audience = null; + + /** + * Whether the time-based claims ("exp"/"nbf"/"iat") are enforced. + */ + protected bool $validateTime = true; + + /** + * Clock-skew tolerance in seconds applied to the time-based claims. + */ + protected int $leeway = 0; + + /** + * The JWS "alg" header the token must carry (e.g. "RS256", "HS256"). + */ + abstract protected function getAlgorithm(): string; + + /** + * Check the raw (binary) signature against the signing input. + */ + abstract protected function verifySignature(string $signingInput, string $signature): bool; + + /** + * Require the token's "iss" claim to equal $issuer. + */ + public function setIssuer(string $issuer): static + { + $this->issuer = $issuer; + + return $this; + } + + /** + * Require the token's "aud" claim to contain at least one of these values. + * + * @param string|array $audience + */ + public function setAudience(string|array $audience): static + { + $this->audience = \is_array($audience) ? array_values($audience) : [$audience]; + + return $this; + } + + /** + * Accept tokens whose lifetime has lapsed by skipping the time-based + * claims. Useful for an OIDC `id_token_hint`, where the spec requires the + * OP to accept an expired hint for a current or recent session. + */ + public function allowExpired(bool $allow = true): static + { + $this->validateTime = !$allow; + + return $this; + } + + /** + * Allow up to $seconds of clock skew when checking the time-based claims. + */ + public function setLeeway(int $seconds): static + { + if ($seconds < 0) { + throw new \InvalidArgumentException('Leeway cannot be negative'); + } + + $this->leeway = $seconds; + + return $this; + } + + /** + * Verify a compact JWS and return its claims. + * + * The signature is checked first (so claims from a forged token are never + * trusted), then the "alg" header, then the configured claim expectations. + * + * @return array + * + * @throws VerificationException When the token is malformed, the signature + * is invalid, or a claim fails validation. + */ + public function verify(string $token): array + { + $segments = explode('.', $token); + if (\count($segments) !== 3) { + throw new VerificationException('Token must have three segments'); + } + + [$encodedHeader, $encodedClaims, $encodedSignature] = $segments; + + $header = $this->decodeSegment($encodedHeader, 'header'); + $claims = $this->decodeSegment($encodedClaims, 'claims'); + + $signature = $this->base64UrlDecode($encodedSignature); + if ($signature === false) { + throw new VerificationException('Signature is not valid base64url'); + } + + // Reject "none" and any algorithm other than ours before touching the + // key, closing the classic algorithm-confusion hole. + if (($header[Header::Algorithm->value] ?? null) !== $this->getAlgorithm()) { + throw new VerificationException('Unexpected token algorithm'); + } + + if (!$this->verifySignature("{$encodedHeader}.{$encodedClaims}", $signature)) { + throw new VerificationException('Signature verification failed'); + } + + $this->validateClaims($claims); + + return $claims; + } + + /** + * Validate the registered claims against the configured expectations. + * + * @param array $claims + * + * @throws VerificationException + */ + protected function validateClaims(array $claims): void + { + $now = time(); + + if ($this->validateTime) { + $exp = $claims[Claim::Expiration->value] ?? null; + if ($exp !== null) { + if (!is_numeric($exp)) { + throw new VerificationException('Invalid "exp" claim'); + } + if ($now >= (int) $exp + $this->leeway) { + throw new VerificationException('Token has expired'); + } + } + + $nbf = $claims[Claim::NotBefore->value] ?? null; + if ($nbf !== null) { + if (!is_numeric($nbf)) { + throw new VerificationException('Invalid "nbf" claim'); + } + if ($now + $this->leeway < (int) $nbf) { + throw new VerificationException('Token is not yet valid'); + } + } + + $iat = $claims[Claim::IssuedAt->value] ?? null; + if ($iat !== null) { + if (!is_numeric($iat)) { + throw new VerificationException('Invalid "iat" claim'); + } + if ($now + $this->leeway < (int) $iat) { + throw new VerificationException('Token was issued in the future'); + } + } + } + + if ($this->issuer !== null && ($claims[Claim::Issuer->value] ?? null) !== $this->issuer) { + throw new VerificationException('Unexpected token issuer'); + } + + if ($this->audience !== null && !$this->audienceMatches($claims[Claim::Audience->value] ?? null)) { + throw new VerificationException('Unexpected token audience'); + } + } + + /** + * Whether the token's "aud" claim (a string or list per RFC 7519 §4.1.3) + * contains any of the configured acceptable audiences. + */ + private function audienceMatches(mixed $aud): bool + { + $tokenAudiences = \is_array($aud) ? $aud : [$aud]; + + foreach ($this->audience ?? [] as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * Base64url-decode then JSON-decode a token segment into an object. + * + * @return array + * + * @throws VerificationException When the segment is not base64url, not JSON, + * or not a JSON object. + */ + private function decodeSegment(string $segment, string $name): array + { + $label = ucfirst($name); + + $decoded = $this->base64UrlDecode($segment); + if ($decoded === false) { + throw new VerificationException("{$label} is not valid base64url"); + } + + try { + $data = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new VerificationException("{$label} is not valid JSON"); + } + + if (!\is_array($data)) { + throw new VerificationException("{$label} must be a JSON object"); + } + + /** @var array $data */ + return $data; + } + + /** + * Base64url-decode without requiring padding (RFC 7515 §2). Returns false + * on input outside the base64url alphabet. + */ + protected function base64UrlDecode(string $value): string|false + { + return base64_decode(strtr($value, '-_', '+/'), true); + } +} diff --git a/packages/auth/src/Auth/Verifiers/Asymmetric.php b/packages/auth/src/Auth/Verifiers/Asymmetric.php new file mode 100644 index 00000000..7c8e724c --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/Asymmetric.php @@ -0,0 +1,89 @@ +publicKey = $publicKey; + } + + /** + * Derive the JWS "kid" deterministically from the RSA modulus, matching + * {@see \Utopia\Auth\Issuers\Asymmetric::getKeyId()} so issuer and verifier + * agree on the key id for the same key. + * + * @throws VerificationException When the public key cannot be parsed. + */ + public function getKeyId(): string + { + return hash('sha256', $this->getModulus()); + } + + protected function getAlgorithm(): string + { + return 'RS256'; + } + + /** + * @throws VerificationException When the public key cannot be parsed. + */ + protected function verifySignature(string $signingInput, string $signature): bool + { + $publicKey = openssl_pkey_get_public($this->publicKey); + if ($publicKey === false) { + throw new VerificationException('Unable to parse the public key'); + } + + $details = openssl_pkey_get_details($publicKey); + if ($details === false || ($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA) { + throw new VerificationException('Public key is not an RSA key'); + } + + return openssl_verify($signingInput, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1; + } + + /** + * Read the raw RSA modulus (the "n" parameter) from the public key. + * + * @throws VerificationException When the public key cannot be parsed. + */ + protected function getModulus(): string + { + $publicKey = openssl_pkey_get_public($this->publicKey); + if ($publicKey === false) { + throw new VerificationException('Unable to parse the public key'); + } + + $details = openssl_pkey_get_details($publicKey); + if ($details === false || !isset($details['rsa']['n'])) { + throw new VerificationException('Public key is not an RSA key'); + } + + return $details['rsa']['n']; + } +} diff --git a/packages/auth/src/Auth/Verifiers/Symmetric.php b/packages/auth/src/Auth/Verifiers/Symmetric.php new file mode 100644 index 00000000..dfca71e0 --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/Symmetric.php @@ -0,0 +1,47 @@ +secret = $secret; + } + + protected function getAlgorithm(): string + { + return 'HS256'; + } + + protected function verifySignature(string $signingInput, string $signature): bool + { + $expected = hash_hmac('sha256', $signingInput, $this->secret, true); + + return hash_equals($expected, $signature); + } +} diff --git a/packages/auth/src/Auth/Verifiers/VerificationException.php b/packages/auth/src/Auth/Verifiers/VerificationException.php new file mode 100644 index 00000000..fe04cd8d --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/VerificationException.php @@ -0,0 +1,10 @@ +privateKey, $this->publicKey] = AccessToken::generateKeyPair(); + $this->issuer = new AccessToken($this->privateKey, $this->publicKey, $this->iss); + $this->verifier = new Asymmetric($this->publicKey); + } + + public function testVerifiesIssuedToken(): void + { + $token = $this->issuer->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read', 'write']); + $claims = $this->verifier->verify($token); + + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals($this->iss, $claims['iss']); + $this->assertEquals(['https://api.example.com'], $claims['aud']); + $this->assertEquals('read write', $claims['scope']); + } + + public function testKeyIdMatchesIssuer(): void + { + // Issuer and verifier must agree on the "kid" for the same key. + $this->assertEquals($this->issuer->getKeyId(), $this->verifier->getKeyId()); + } + + public function testIssuerCheckPasses(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $claims = $this->verifier->setIssuer($this->iss)->verify($token); + + $this->assertEquals($this->iss, $claims['iss']); + } + + public function testIssuerMismatchRejected(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Unexpected token issuer'); + $this->verifier->setIssuer('https://evil.example.com')->verify($token); + } + + public function testAudienceMembershipPasses(): void + { + $token = $this->issuer->issue('u', ['https://a.example.com', 'https://b.example.com'], 'c', 1000, 3600); + $claims = $this->verifier->setAudience('https://b.example.com')->verify($token); + + $this->assertContains('https://b.example.com', $claims['aud']); + } + + public function testAudienceMismatchRejected(): void + { + $token = $this->issuer->issue('u', ['https://a.example.com'], 'c', 1000, 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Unexpected token audience'); + $this->verifier->setAudience('https://other.example.com')->verify($token); + } + + public function testExpiredTokenRejected(): void + { + // A negative duration puts "exp" in the past. + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, -3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token has expired'); + $this->verifier->verify($token); + } + + public function testExpiredTokenAcceptedWhenAllowed(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, -3600); + $claims = $this->verifier->allowExpired()->verify($token); + + $this->assertEquals('u', $claims['sub']); + } + + public function testTamperedSignatureRejected(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $parts = explode('.', $token); + $sig = $parts[2]; + // Flip the first base64url char: it encodes the high bits of the first + // signature byte and is always significant, so the corruption is + // deterministic (unlike the last char, whose low bits are padding). + $first = $sig[0]; + $parts[2] = ($first === 'A' ? 'B' : 'A') . substr($sig, 1); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Signature verification failed'); + $this->verifier->verify(implode('.', $parts)); + } + + public function testTamperedClaimsRejected(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $parts = explode('.', $token); + // Swap the payload for a forged one while keeping the original signature. + $parts[1] = rtrim(strtr(base64_encode((string) json_encode(['sub' => 'attacker'])), '+/', '-_'), '='); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Signature verification failed'); + $this->verifier->verify(implode('.', $parts)); + } + + public function testWrongKeyRejected(): void + { + [, $otherPublic] = AccessToken::generateKeyPair(); + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Signature verification failed'); + (new Asymmetric($otherPublic))->verify($token); + } + + public function testAlgorithmMismatchRejected(): void + { + // An HS256 token must never be accepted by the RS256 verifier. + $hsToken = (new RefreshToken('a-shared-secret', $this->iss))->issue('u', 'aud', 'c', 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Unexpected token algorithm'); + $this->verifier->verify($hsToken); + } + + public function testMalformedTokenRejected(): void + { + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token must have three segments'); + $this->verifier->verify('not-a-jwt'); + } +} diff --git a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php new file mode 100644 index 00000000..c46255bc --- /dev/null +++ b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php @@ -0,0 +1,75 @@ +secret = RefreshToken::generateSecret(); + $this->issuer = new RefreshToken($this->secret, $this->iss); + $this->verifier = new Symmetric($this->secret); + } + + public function testVerifiesIssuedToken(): void + { + $token = $this->issuer->issue('user-123', 'https://example.com/token', 'client-abc', 3600, ['offline_access']); + $claims = $this->verifier + ->setIssuer($this->iss) + ->setAudience('https://example.com/token') + ->verify($token); + + $this->assertEquals('user-123', $claims['sub']); + $this->assertEquals('client-abc', $claims['client_id']); + $this->assertEquals('offline_access', $claims['scope']); + } + + public function testWrongSecretRejected(): void + { + $token = $this->issuer->issue('u', 'aud', 'c', 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Signature verification failed'); + (new Symmetric(RefreshToken::generateSecret()))->verify($token); + } + + public function testExpiredTokenRejected(): void + { + $token = $this->issuer->issue('u', 'aud', 'c', -3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token has expired'); + $this->verifier->verify($token); + } + + public function testAudienceMismatchRejected(): void + { + $token = $this->issuer->issue('u', 'aud', 'c', 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Unexpected token audience'); + $this->verifier->setAudience('other')->verify($token); + } + + public function testLeewayAllowsRecentlyExpired(): void + { + // Expired 10 seconds ago, but a 60s leeway tolerates the skew. + $token = $this->issuer->issue('u', 'aud', 'c', -10); + $claims = $this->verifier->setLeeway(60)->verify($token); + + $this->assertEquals('u', $claims['sub']); + } +} From 11747830797c5638728524168b17ff26e4d8bac9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:24:34 +0530 Subject: [PATCH 2/8] (docs): document verifier getKeyId helper, enums, and point tests badge at the auth repo --- packages/auth/README.md | 2 +- packages/auth/docs/jwt.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/auth/README.md b/packages/auth/README.md index e9bef618..359a4851 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -3,7 +3,7 @@ > [!IMPORTANT] > This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/auth`](https://github.com/utopia-php/monorepo/tree/main/packages/auth) — please open issues and pull requests there. -[![Tests](https://github.com/utopia-php/monorepo/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/monorepo/actions/workflows/tests.yml) +[![Tests](https://github.com/utopia-php/auth/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/auth/actions/workflows/tests.yml) ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/auth.svg) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) diff --git a/packages/auth/docs/jwt.md b/packages/auth/docs/jwt.md index 0954e766..7de73ea7 100644 --- a/packages/auth/docs/jwt.md +++ b/packages/auth/docs/jwt.md @@ -166,3 +166,31 @@ $claims = (new Symmetric($secret)) ->setAudience('https://example.com/token') ->verify($refreshToken); ``` + +`Verifiers\Asymmetric` also exposes `getKeyId()`, which derives the JWS `kid` +deterministically from the public key the same way the issuer does — useful for +matching a token's `kid` header or selecting the right key from a JWKS: + +```php +$verifier = new Asymmetric($publicKey); +$kid = $verifier->getKeyId(); // matches the issuer's getKeyId() for the same key +``` + +## Claim and header names + +The claim and header names used above are also available as string-backed enums, +so you can reference them without magic strings when reading verified claims: + +```php +value]; // 'sub' +$algorithm = Header::Algorithm->value; // 'alg' +``` + +`Claim` covers the RFC 7519 registered claims plus the OAuth2 (RFC 9068) and +OpenID Connect claims this library issues; `Header` covers the RFC 7515 JOSE +header parameters (`typ`, `alg`, `kid`). From 8be745e4a2221cbb8d4f00004678da08a7e37719 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:25:14 +0530 Subject: [PATCH 3/8] (docs): remove broken build status badge --- packages/auth/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/auth/README.md b/packages/auth/README.md index 359a4851..bd02881d 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -3,7 +3,6 @@ > [!IMPORTANT] > This repository is a read-only mirror of the [utopia-php monorepo](https://github.com/utopia-php/monorepo). Development happens in [`packages/auth`](https://github.com/utopia-php/monorepo/tree/main/packages/auth) — please open issues and pull requests there. -[![Tests](https://github.com/utopia-php/auth/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/auth/actions/workflows/tests.yml) ![Total Downloads](https://img.shields.io/packagist/dt/utopia-php/auth.svg) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord)](https://appwrite.io/discord) From 5d45cd58910a661a9442d856fba6c2e9052008d1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:33:01 +0530 Subject: [PATCH 4/8] (refactor): promote set-once keys to readonly and default the Proof hash via initializer --- packages/auth/src/Auth/Issuer.php | 14 +++------ packages/auth/src/Auth/Issuers/Asymmetric.php | 30 ++++--------------- packages/auth/src/Auth/Issuers/Symmetric.php | 19 ++---------- packages/auth/src/Auth/Proof.php | 4 +-- packages/auth/src/Auth/Proofs/Password.php | 1 - .../auth/src/Auth/Verifiers/Asymmetric.php | 11 ++----- .../auth/src/Auth/Verifiers/Symmetric.php | 11 ++----- 7 files changed, 18 insertions(+), 72 deletions(-) diff --git a/packages/auth/src/Auth/Issuer.php b/packages/auth/src/Auth/Issuer.php index f6bc231c..efb0644e 100644 --- a/packages/auth/src/Auth/Issuer.php +++ b/packages/auth/src/Auth/Issuer.php @@ -17,23 +17,17 @@ abstract class Issuer { /** - * The token issuer (the "iss" claim). For OAuth2/OIDC this is the URL of - * the authorization server, e.g. "https://example.com/v1/oauth2/". - */ - protected string $issuer; - - /** - * @param string $issuer The "iss" claim value. + * @param string $issuer The token issuer (the "iss" claim). For OAuth2/OIDC + * this is the URL of the authorization server, + * e.g. "https://example.com/v1/oauth2/". * * @throws \Exception When the issuer is missing. */ - public function __construct(string $issuer) + public function __construct(protected readonly string $issuer) { if (empty($issuer)) { throw new \Exception('An issuer is required'); } - - $this->issuer = $issuer; } /** diff --git a/packages/auth/src/Auth/Issuers/Asymmetric.php b/packages/auth/src/Auth/Issuers/Asymmetric.php index 1716d032..01a477c9 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric.php @@ -16,44 +16,24 @@ abstract class Asymmetric extends Issuer { /** - * PEM-encoded RSA private key used to sign tokens. - */ - protected string $privateKey; - - /** - * PEM-encoded RSA public key matching the private key. Used to derive - * the key id (kid) and to expose the public JWK for verification. - */ - protected string $publicKey; - - /** - * The JWS "kid" header. When null it is derived from the public key. - */ - protected ?string $keyId; - - /** - * @param string $privateKey PEM-encoded RSA private key, generate using {@see generateKeyPair()}. - * @param string $publicKey PEM-encoded RSA public key, generate using {@see generateKeyPair()}. + * @param string $privateKey PEM-encoded RSA private key used to sign tokens, generate using {@see generateKeyPair()}. + * @param string $publicKey PEM-encoded RSA public key matching the private key, generate using {@see generateKeyPair()}. * @param string $issuer The "iss" claim value. * @param string|null $keyId Optional "kid" header; derived from the public key when null. * * @throws \Exception When a key or the issuer is missing. */ public function __construct( - string $privateKey, - string $publicKey, + protected readonly string $privateKey, + protected readonly string $publicKey, string $issuer, - ?string $keyId = null, + protected ?string $keyId = null, ) { parent::__construct($issuer); if (empty($privateKey) || empty($publicKey)) { throw new \Exception('Both a private and a public key are required'); } - - $this->privateKey = $privateKey; - $this->publicKey = $publicKey; - $this->keyId = $keyId; } /** diff --git a/packages/auth/src/Auth/Issuers/Symmetric.php b/packages/auth/src/Auth/Issuers/Symmetric.php index f0ccd8a6..9f882bdf 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric.php +++ b/packages/auth/src/Auth/Issuers/Symmetric.php @@ -15,36 +15,23 @@ */ abstract class Symmetric extends Issuer { - /** - * The shared secret used to sign and verify tokens. - */ - protected string $secret; - - /** - * Optional JWS "kid" header, useful when rotating secrets. Omitted when null. - */ - protected ?string $keyId; - /** * @param string $secret The shared signing secret, generate using {@see generateSecret()}. * @param string $issuer The "iss" claim value. - * @param string|null $keyId Optional "kid" header; omitted when null. + * @param string|null $keyId Optional "kid" header, useful when rotating secrets; omitted when null. * * @throws \Exception When the secret or the issuer is missing. */ public function __construct( - string $secret, + protected readonly string $secret, string $issuer, - ?string $keyId = null, + protected readonly ?string $keyId = null, ) { parent::__construct($issuer); if (empty($secret)) { throw new \Exception('A signing secret is required'); } - - $this->secret = $secret; - $this->keyId = $keyId; } /** diff --git a/packages/auth/src/Auth/Proof.php b/packages/auth/src/Auth/Proof.php index f3948eb2..6d821582 100644 --- a/packages/auth/src/Auth/Proof.php +++ b/packages/auth/src/Auth/Proof.php @@ -8,9 +8,9 @@ abstract class Proof { protected Hash $hash; - public function __construct() + public function __construct(Hash $hash = new Argon2()) { - $this->hash = new Argon2(); + $this->hash = $hash; } /** diff --git a/packages/auth/src/Auth/Proofs/Password.php b/packages/auth/src/Auth/Proofs/Password.php index cedf00a2..f9477e56 100644 --- a/packages/auth/src/Auth/Proofs/Password.php +++ b/packages/auth/src/Auth/Proofs/Password.php @@ -60,7 +60,6 @@ public function __construct(array $hashes = []) } $this->hashes = $hashes; - $this->hash = new Argon2(); // Set the first hash as the default one } /** diff --git a/packages/auth/src/Auth/Verifiers/Asymmetric.php b/packages/auth/src/Auth/Verifiers/Asymmetric.php index 7c8e724c..7ff3a223 100644 --- a/packages/auth/src/Auth/Verifiers/Asymmetric.php +++ b/packages/auth/src/Auth/Verifiers/Asymmetric.php @@ -14,22 +14,15 @@ class Asymmetric extends Verifier { /** - * PEM-encoded RSA public key used to verify the signature. - */ - protected string $publicKey; - - /** - * @param string $publicKey PEM-encoded RSA public key. + * @param string $publicKey PEM-encoded RSA public key used to verify the signature. * * @throws \Exception When the public key is missing. */ - public function __construct(string $publicKey) + public function __construct(protected readonly string $publicKey) { if (empty($publicKey)) { throw new \Exception('A public key is required'); } - - $this->publicKey = $publicKey; } /** diff --git a/packages/auth/src/Auth/Verifiers/Symmetric.php b/packages/auth/src/Auth/Verifiers/Symmetric.php index dfca71e0..7039d079 100644 --- a/packages/auth/src/Auth/Verifiers/Symmetric.php +++ b/packages/auth/src/Auth/Verifiers/Symmetric.php @@ -15,22 +15,15 @@ class Symmetric extends Verifier { /** - * The shared secret used to sign and verify tokens. - */ - protected string $secret; - - /** - * @param string $secret The shared signing secret. + * @param string $secret The shared signing secret used to verify the signature. * * @throws \Exception When the secret is missing. */ - public function __construct(string $secret) + public function __construct(protected readonly string $secret) { if (empty($secret)) { throw new \Exception('A signing secret is required'); } - - $this->secret = $secret; } protected function getAlgorithm(): string From 6cf0501556362d59bb7d9fe4b53286c19f0ba4ba Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:41:19 +0530 Subject: [PATCH 5/8] (chore): enable phpstan and rector for the auth package and apply their fixes --- packages/auth/phpstan.neon | 5 ++ packages/auth/rector.php | 21 +++++++ packages/auth/src/Auth/Enums/Claim.php | 2 + packages/auth/src/Auth/Enums/Header.php | 2 + packages/auth/src/Auth/Hash.php | 2 + packages/auth/src/Auth/Hashes/Argon2.php | 2 + packages/auth/src/Auth/Hashes/Bcrypt.php | 2 + packages/auth/src/Auth/Hashes/MD5.php | 2 + packages/auth/src/Auth/Hashes/PHPass.php | 10 +--- packages/auth/src/Auth/Hashes/Plaintext.php | 2 + packages/auth/src/Auth/Hashes/Scrypt.php | 2 +- .../auth/src/Auth/Hashes/ScryptModified.php | 4 +- packages/auth/src/Auth/Hashes/Sha.php | 2 + packages/auth/src/Auth/Issuer.php | 2 +- packages/auth/src/Auth/Issuers/Asymmetric.php | 8 +-- .../Auth/Issuers/Asymmetric/AccessToken.php | 6 +- .../src/Auth/Issuers/Asymmetric/IdToken.php | 8 ++- packages/auth/src/Auth/Issuers/Symmetric.php | 2 +- .../Auth/Issuers/Symmetric/RefreshToken.php | 4 +- .../Auth/OAuth2/InvalidResourceException.php | 2 + .../src/Auth/OAuth2/ResourceIndicators.php | 8 +-- packages/auth/src/Auth/Proof.php | 9 +-- packages/auth/src/Auth/Proofs/Code.php | 2 + packages/auth/src/Auth/Proofs/Phrase.php | 10 +--- packages/auth/src/Auth/Verifier.php | 2 + .../auth/src/Auth/Verifiers/Asymmetric.php | 2 +- .../auth/src/Auth/Verifiers/Symmetric.php | 4 +- .../Auth/Verifiers/VerificationException.php | 2 + .../auth/tests/Auth/Algorithms/Argon2Test.php | 7 ++- .../auth/tests/Auth/Algorithms/BcryptTest.php | 7 ++- .../auth/tests/Auth/Algorithms/MD5Test.php | 19 ++++--- .../auth/tests/Auth/Algorithms/PHPassTest.php | 7 ++- .../tests/Auth/Algorithms/PlaintextTest.php | 15 ++--- .../Auth/Algorithms/ScryptModifiedTest.php | 7 ++- .../auth/tests/Auth/Algorithms/ScryptTest.php | 7 ++- .../auth/tests/Auth/Algorithms/ShaTest.php | 7 ++- packages/auth/tests/Auth/HashTest.php | 8 ++- .../Issuers/Asymmetric/AccessTokenTest.php | 12 ++-- .../Auth/Issuers/Asymmetric/IdTokenTest.php | 12 ++-- .../Issuers/Symmetric/RefreshTokenTest.php | 12 ++-- .../Auth/OAuth2/ResourceIndicatorsTest.php | 56 +++++++++---------- packages/auth/tests/Auth/Proofs/CodeTest.php | 24 ++++---- .../auth/tests/Auth/Proofs/PasswordTest.php | 14 ++--- .../auth/tests/Auth/Proofs/PhraseTest.php | 6 +- packages/auth/tests/Auth/Proofs/TokenTest.php | 18 +++--- .../tests/Auth/Verifiers/AsymmetricTest.php | 16 +++--- .../tests/Auth/Verifiers/SymmetricTest.php | 12 ++-- packages/auth/tests/StoreTest.php | 6 +- 48 files changed, 229 insertions(+), 172 deletions(-) create mode 100644 packages/auth/phpstan.neon create mode 100644 packages/auth/rector.php diff --git a/packages/auth/phpstan.neon b/packages/auth/phpstan.neon new file mode 100644 index 00000000..46b0f477 --- /dev/null +++ b/packages/auth/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 5 + paths: + - src + - tests diff --git a/packages/auth/rector.php b/packages/auth/rector.php new file mode 100644 index 00000000..77a88b28 --- /dev/null +++ b/packages/auth/rector.php @@ -0,0 +1,21 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withSkip([ + __DIR__ . '/vendor', + ]) + ->withPhpSets(php81: true) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + phpunitCodeQuality: true, + ); diff --git a/packages/auth/src/Auth/Enums/Claim.php b/packages/auth/src/Auth/Enums/Claim.php index e6a38d02..1b7eff09 100644 --- a/packages/auth/src/Auth/Enums/Claim.php +++ b/packages/auth/src/Auth/Enums/Claim.php @@ -1,5 +1,7 @@ getOptions(); $random = ''; - if (CRYPT_BLOWFISH === 1 && ! $options['portable_hashes']) { + if (! $options['portable_hashes']) { $random = $this->getRandomBytes(16); $hash = crypt($value, $this->gensaltBlowfish($random)); if (\strlen($hash) === 60) { @@ -143,9 +143,8 @@ private function gensaltPrivate(string $input): string $options = $this->getOptions(); $output = '$P$'; $output .= $this->itoa64[min($options['iteration_count_log2'] + ((PHP_VERSION >= '5') ? 5 : 3), 30)]; - $output .= $this->encode64($input, 6); - return $output; + return $output . $this->encode64($input, 6); } /** @@ -218,16 +217,14 @@ private function cryptPrivate(string $password, string $setting): string } while (--$count); $output = substr($setting, 0, 12); - $output .= $this->encode64($hash, 16); - return $output; + return $output . $this->encode64($hash, 16); } /** * Set iteration count (log2) * * @param int $count Iteration count (log2) between 4 and 31 - * @return static * * @throws \InvalidArgumentException */ @@ -246,7 +243,6 @@ public function setIterationCount(int $count): PHPass * Set portable hashes mode * * @param bool $portable Whether to use portable hashes - * @return static */ public function setPortableHashes(bool $portable): PHPass { diff --git a/packages/auth/src/Auth/Hashes/Plaintext.php b/packages/auth/src/Auth/Hashes/Plaintext.php index 50fbc465..be865233 100644 --- a/packages/auth/src/Auth/Hashes/Plaintext.php +++ b/packages/auth/src/Auth/Hashes/Plaintext.php @@ -1,5 +1,7 @@ keyId ??= self::deriveKeyId($this->getModulus()); + return $this->keyId ??= $this->deriveKeyId($this->getModulus()); } /** @@ -131,7 +131,7 @@ public function getPublicJwk(): array 'alg' => 'RS256', // Reuse the modulus already in $details rather than re-parsing // the key via getKeyId() -> getModulus(). - 'kid' => $this->keyId ??= self::deriveKeyId($details['rsa']['n']), + 'kid' => $this->keyId ??= $this->deriveKeyId($details['rsa']['n']), 'n' => $this->base64UrlEncode($details['rsa']['n']), 'e' => $this->base64UrlEncode($details['rsa']['e']), ]; @@ -174,7 +174,7 @@ protected function signInput(string $signingInput): string * Derive a deterministic key id from the RSA modulus, so the same key * always yields the same "kid". */ - private static function deriveKeyId(string $modulus): string + private function deriveKeyId(string $modulus): string { return hash('sha256', $modulus); } diff --git a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php index f688bc48..45c658fe 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -1,5 +1,7 @@ value => $authTime, ]; - if (!empty($scopes)) { + if ($scopes !== []) { $claims[Claim::Scope->value] = implode(' ', $scopes); } diff --git a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php b/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php index de15e0c6..2d958aa1 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php @@ -1,5 +1,7 @@ value => $authTime, ]; - if (!empty($nonce)) { + if (!\in_array($nonce, [null, '', '0'], true)) { $claims[Claim::Nonce->value] = $nonce; } - if (!empty($accessToken)) { + if (!\in_array($accessToken, [null, '', '0'], true)) { $claims[Claim::AccessTokenHash->value] = $this->leftHalfHash($accessToken); } - if (!empty($code)) { + if (!\in_array($code, [null, '', '0'], true)) { $claims[Claim::CodeHash->value] = $this->leftHalfHash($code); } diff --git a/packages/auth/src/Auth/Issuers/Symmetric.php b/packages/auth/src/Auth/Issuers/Symmetric.php index 9f882bdf..cf5f078d 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric.php +++ b/packages/auth/src/Auth/Issuers/Symmetric.php @@ -29,7 +29,7 @@ public function __construct( ) { parent::__construct($issuer); - if (empty($secret)) { + if ($secret === '' || $secret === '0') { throw new \Exception('A signing secret is required'); } } diff --git a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php index 5f280e66..2af278c8 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php +++ b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -1,5 +1,7 @@ value => $jti ?? $this->generateJti(), ]; - if (!empty($scopes)) { + if ($scopes !== []) { $claims[Claim::Scope->value] = implode(' ', $scopes); } diff --git a/packages/auth/src/Auth/OAuth2/InvalidResourceException.php b/packages/auth/src/Auth/OAuth2/InvalidResourceException.php index d73e3866..10d8cdde 100644 --- a/packages/auth/src/Auth/OAuth2/InvalidResourceException.php +++ b/packages/auth/src/Auth/OAuth2/InvalidResourceException.php @@ -1,5 +1,7 @@ */ - private array $resources; + private readonly array $resources; /** * @param array $resources @@ -25,7 +25,7 @@ private function __construct(array $resources) case !\is_string($resource) || $resource === '': throw new InvalidResourceException('resource must be a non-empty absolute URI.'); - case \is_string($resource) && !self::isValid($resource): + case !$this->isValid($resource): throw new InvalidResourceException('resource must be an absolute HTTP(S) URI with no fragment component.'); case \in_array($resource, $seen, true): @@ -68,7 +68,7 @@ public static function from(string|array|null $value): self */ public function isSubsetOf(self $granted): bool { - return empty(array_diff($this->resources, $granted->resources)); + return array_diff($this->resources, $granted->resources) === []; } public function equals(self $resources): bool @@ -101,7 +101,7 @@ public function toArray(): array return $this->resources; } - private static function isValid(string $resource): bool + private function isValid(string $resource): bool { $parts = parse_url($resource); diff --git a/packages/auth/src/Auth/Proof.php b/packages/auth/src/Auth/Proof.php index 6d821582..eba80fed 100644 --- a/packages/auth/src/Auth/Proof.php +++ b/packages/auth/src/Auth/Proof.php @@ -1,17 +1,14 @@ hash = $hash; - } + public function __construct(protected Hash $hash = new Argon2()) {} /** * Set custom hash diff --git a/packages/auth/src/Auth/Proofs/Code.php b/packages/auth/src/Auth/Proofs/Code.php index df564484..4933982a 100644 --- a/packages/auth/src/Auth/Proofs/Code.php +++ b/packages/auth/src/Auth/Proofs/Code.php @@ -1,5 +1,7 @@ */ private array $nouns = ['apple', 'banana', 'cat', 'dog', 'elephant', 'fish', 'guitar', 'hat', 'ice cream', 'jacket', 'kangaroo', 'lemon', 'moon', 'notebook', 'orange', 'piano', 'quilt', 'rabbit', 'sun', 'tree', 'umbrella', 'violin', 'watermelon', 'xylophone', 'yogurt', 'zebra', 'airplane', 'ball', 'cloud', 'diamond', 'eagle', 'fire', 'giraffe', 'hammer', 'island', 'jellyfish', 'kiwi', 'lamp', 'mango', 'needle', 'ocean', 'pear', 'quasar', 'rose', 'star', 'turtle', 'unicorn', 'volcano', 'whale', 'xylograph', 'yarn', 'zephyr', 'ant', 'book', 'candle', 'door', 'envelope', 'feather', 'globe', 'harp', 'insect', 'jar', 'kite', 'lighthouse', 'magnet', 'necklace', 'owl', 'puzzle', 'queen', 'rainbow', 'sailboat', 'telescope', 'umbrella', 'vase', 'wallet', 'xylograph', 'yacht', 'zeppelin', 'accordion', 'brush', 'chocolate', 'dolphin', 'easel', 'fountain', 'globe', 'hairbrush', 'iceberg', 'jigsaw', 'kettle', 'leopard', 'marble', 'nutmeg', 'obstacle', 'penguin', 'quiver', 'raccoon', 'sphinx', 'trampoline', 'utensil', 'velvet', 'wagon', 'xerox', 'yodel', 'zipper']; - /** - * Constructor - */ - public function __construct() - { - parent::__construct(); - } - /** * Generate a proof */ diff --git a/packages/auth/src/Auth/Verifier.php b/packages/auth/src/Auth/Verifier.php index 0032117c..5abc0204 100644 --- a/packages/auth/src/Auth/Verifier.php +++ b/packages/auth/src/Auth/Verifier.php @@ -1,5 +1,7 @@ argon2->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertStringStartsWith('$argon2id$', $hash); $this->assertStringContainsString('m=' . $this->argon2->getOption('memory_cost'), $hash); $this->assertStringContainsString('t=' . $this->argon2->getOption('time_cost'), $hash); @@ -66,6 +67,6 @@ public function testValidThreads(): void public function testGetName(): void { - $this->assertEquals('argon2', $this->argon2->getName()); + $this->assertSame('argon2', $this->argon2->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/BcryptTest.php b/packages/auth/tests/Auth/Algorithms/BcryptTest.php index 7b1ec62e..6e6a7a3a 100644 --- a/packages/auth/tests/Auth/Algorithms/BcryptTest.php +++ b/packages/auth/tests/Auth/Algorithms/BcryptTest.php @@ -1,11 +1,13 @@ bcrypt->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertStringStartsWith('$2y$', $hash); $this->assertTrue($this->bcrypt->verify($password, $hash)); $this->assertFalse($this->bcrypt->verify('wrongpassword', $hash)); @@ -28,6 +29,6 @@ public function testHash(): void public function testGetName(): void { - $this->assertEquals('bcrypt', $this->bcrypt->getName()); + $this->assertSame('bcrypt', $this->bcrypt->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/MD5Test.php b/packages/auth/tests/Auth/Algorithms/MD5Test.php index 3c1ee6b7..ee29bb02 100644 --- a/packages/auth/tests/Auth/Algorithms/MD5Test.php +++ b/packages/auth/tests/Auth/Algorithms/MD5Test.php @@ -1,11 +1,13 @@ md5->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); - $this->assertEquals(32, \strlen($hash)); - $this->assertEquals(md5($password), $hash); + $this->assertSame(32, \strlen($hash)); + $this->assertSame(md5($password), $hash); $this->assertTrue($this->md5->verify($password, $hash)); $this->assertFalse($this->md5->verify('wrongpassword', $hash)); } @@ -33,7 +34,7 @@ public function testMultipleHashes(): void foreach ($passwords as $password) { $hash = $this->md5->hash($password); - $this->assertEquals(md5($password), $hash); + $this->assertSame(md5($password), $hash); $this->assertTrue($this->md5->verify($password, $hash)); } } @@ -43,7 +44,7 @@ public function testEmptyString(): void $password = ''; $hash = $this->md5->hash($password); - $this->assertEquals(md5(''), $hash); + $this->assertSame(md5(''), $hash); $this->assertTrue($this->md5->verify($password, $hash)); } @@ -52,7 +53,7 @@ public function testSpecialCharacters(): void $password = '!@#$%^&*()_+-=[]{}|;:,.<>?'; $hash = $this->md5->hash($password); - $this->assertEquals(md5($password), $hash); + $this->assertSame(md5($password), $hash); $this->assertTrue($this->md5->verify($password, $hash)); } @@ -61,12 +62,12 @@ public function testUnicodeCharacters(): void $password = 'Hello 世界'; $hash = $this->md5->hash($password); - $this->assertEquals(md5($password), $hash); + $this->assertSame(md5($password), $hash); $this->assertTrue($this->md5->verify($password, $hash)); } public function testGetName(): void { - $this->assertEquals('md5', $this->md5->getName()); + $this->assertSame('md5', $this->md5->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/PHPassTest.php b/packages/auth/tests/Auth/Algorithms/PHPassTest.php index bbb759fa..93a6f1a4 100644 --- a/packages/auth/tests/Auth/Algorithms/PHPassTest.php +++ b/packages/auth/tests/Auth/Algorithms/PHPassTest.php @@ -1,11 +1,13 @@ phpass->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertTrue($this->phpass->verify($password, $hash)); $this->assertFalse($this->phpass->verify('wrongpassword', $hash)); } @@ -85,6 +86,6 @@ public function testLongPassword(): void public function testGetName(): void { - $this->assertEquals('phpass', $this->phpass->getName()); + $this->assertSame('phpass', $this->phpass->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/PlaintextTest.php b/packages/auth/tests/Auth/Algorithms/PlaintextTest.php index 1afb7e88..350d456a 100644 --- a/packages/auth/tests/Auth/Algorithms/PlaintextTest.php +++ b/packages/auth/tests/Auth/Algorithms/PlaintextTest.php @@ -1,11 +1,13 @@ plaintext->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); - $this->assertEquals($password, $hash); + $this->assertSame($password, $hash); $this->assertTrue($this->plaintext->verify($password, $hash)); $this->assertFalse($this->plaintext->verify('wrongpassword', $hash)); } @@ -31,7 +32,7 @@ public function testSpecialCharacters(): void $password = '!@#$%^&*()_+-=[]{}|;:,.<>?'; $hash = $this->plaintext->hash($password); - $this->assertEquals($password, $hash); + $this->assertSame($password, $hash); $this->assertTrue($this->plaintext->verify($password, $hash)); } @@ -40,7 +41,7 @@ public function testUnicodeCharacters(): void $password = 'Hello 世界'; $hash = $this->plaintext->hash($password); - $this->assertEquals($password, $hash); + $this->assertSame($password, $hash); $this->assertTrue($this->plaintext->verify($password, $hash)); } @@ -49,12 +50,12 @@ public function testEmptyString(): void $password = ''; $hash = $this->plaintext->hash($password); - $this->assertEquals($password, $hash); + $this->assertSame($password, $hash); $this->assertTrue($this->plaintext->verify($password, $hash)); } public function testGetName(): void { - $this->assertEquals('plaintext', $this->plaintext->getName()); + $this->assertSame('plaintext', $this->plaintext->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php b/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php index eabcccc0..e49a311f 100644 --- a/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php +++ b/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php @@ -1,11 +1,13 @@ scryptModified->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertTrue($this->scryptModified->verify($password, $hash)); $this->assertFalse($this->scryptModified->verify('wrongpassword', $hash)); } @@ -39,6 +40,6 @@ public function testCustomOptions(): void public function testGetName(): void { - $this->assertEquals('scryptMod', $this->scryptModified->getName()); + $this->assertSame('scryptMod', $this->scryptModified->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/ScryptTest.php b/packages/auth/tests/Auth/Algorithms/ScryptTest.php index 16788c86..5a65eb34 100644 --- a/packages/auth/tests/Auth/Algorithms/ScryptTest.php +++ b/packages/auth/tests/Auth/Algorithms/ScryptTest.php @@ -1,11 +1,13 @@ scrypt->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertTrue($this->scrypt->verify($password, $hash)); $this->assertFalse($this->scrypt->verify('wrongpassword', $hash)); } @@ -50,6 +51,6 @@ public function testCustomOptions(): void public function testGetName(): void { - $this->assertEquals('scrypt', $this->scrypt->getName()); + $this->assertSame('scrypt', $this->scrypt->getName()); } } diff --git a/packages/auth/tests/Auth/Algorithms/ShaTest.php b/packages/auth/tests/Auth/Algorithms/ShaTest.php index a03a02d2..dd146e62 100644 --- a/packages/auth/tests/Auth/Algorithms/ShaTest.php +++ b/packages/auth/tests/Auth/Algorithms/ShaTest.php @@ -1,11 +1,13 @@ sha->hash($password); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertTrue($this->sha->verify($password, $hash)); $this->assertFalse($this->sha->verify('wrongpassword', $hash)); } @@ -42,6 +43,6 @@ public function testInvalidVersion(): void public function testGetName(): void { - $this->assertEquals('sha', $this->sha->getName()); + $this->assertSame('sha', $this->sha->getName()); } } diff --git a/packages/auth/tests/Auth/HashTest.php b/packages/auth/tests/Auth/HashTest.php index 65ec3f46..a20460f3 100644 --- a/packages/auth/tests/Auth/HashTest.php +++ b/packages/auth/tests/Auth/HashTest.php @@ -1,11 +1,13 @@ hash->setOptions($options); // Verify all options were set - $this->assertEquals($options, $this->hash->getOptions()); + $this->assertSame($options, $this->hash->getOptions()); // Verify individual options foreach ($options as $key => $value) { @@ -65,7 +67,7 @@ public function testGetOptions(): void ]; $this->hash->setOptions($options); - $this->assertEquals($options, $this->hash->getOptions()); + $this->assertSame($options, $this->hash->getOptions()); } public function testMethodChaining(): void diff --git a/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php b/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php index 543d9712..0407da94 100644 --- a/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php +++ b/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php @@ -1,24 +1,24 @@ privateKey, $this->publicKey] = AccessToken::generateKeyPair(); + [$privateKey, $this->publicKey] = AccessToken::generateKeyPair(); $this->accessToken = new AccessToken( - $this->privateKey, + $privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test', ); @@ -97,7 +97,7 @@ public function testSignatureIsValid(): void OPENSSL_ALGO_SHA256, ); - $this->assertEquals(1, $result); + $this->assertSame(1, $result); } public function testScopeOmittedWhenEmpty(): void diff --git a/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php b/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php index 2aca8445..9bd0d8a7 100644 --- a/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php +++ b/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php @@ -1,11 +1,13 @@ assertEquals(1, $result); + $this->assertSame(1, $result); } public function testNonceClaim(): void @@ -186,7 +188,7 @@ public function testKeyIdIsDeterministic(): void { $other = new IdToken($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); - $this->assertEquals($this->idToken->getKeyId(), $other->getKeyId()); + $this->assertSame($this->idToken->getKeyId(), $other->getKeyId()); $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->idToken->getKeyId()); } @@ -194,7 +196,7 @@ public function testCustomKeyId(): void { $idToken = new IdToken($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); - $this->assertEquals('my-custom-kid', $idToken->getKeyId()); + $this->assertSame('my-custom-kid', $idToken->getKeyId()); $token = $idToken->issue('user-123', 'client-abc', 1000, 3600); $header = $this->decodeSegment(explode('.', $token)[0]); @@ -253,7 +255,7 @@ public function testGenerateKeyPair(): void $publicKey, OPENSSL_ALGO_SHA256, ); - $this->assertEquals(1, $result); + $this->assertSame(1, $result); } /** diff --git a/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php index a4f23ab5..a0416300 100644 --- a/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php +++ b/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php @@ -1,11 +1,13 @@ base64UrlEncode(hash_hmac('sha256', $parts[0] . '.' . $parts[1], $this->secret, true)); - $this->assertEquals($expected, $parts[2]); + $this->assertSame($expected, $parts[2]); } public function testSignatureFailsWithWrongSecret(): void @@ -88,7 +90,7 @@ public function testSignatureFailsWithWrongSecret(): void $parts = explode('.', $token); $wrong = $this->base64UrlEncode(hash_hmac('sha256', $parts[0] . '.' . $parts[1], 'not-the-secret', true)); - $this->assertNotEquals($wrong, $parts[2]); + $this->assertNotSame($wrong, $parts[2]); } public function testScopeOmittedWhenEmpty(): void @@ -139,7 +141,7 @@ public function testKidHeaderWhenConfigured(): void { $refreshToken = new RefreshToken($this->secret, 'https://example.com/v1/oauth2/test', 'secret-v2'); - $this->assertEquals('secret-v2', $refreshToken->getKeyId()); + $this->assertSame('secret-v2', $refreshToken->getKeyId()); $header = $this->decodeSegment(explode('.', $refreshToken->issue('u', 'a', 'c', 100))[0]); $this->assertEquals('secret-v2', $header['kid']); @@ -162,7 +164,7 @@ public function testGenerateSecret(): void $secret = RefreshToken::generateSecret(); $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $secret); - $this->assertNotEquals($secret, RefreshToken::generateSecret()); + $this->assertNotSame($secret, RefreshToken::generateSecret()); } public function testEmptySecretThrows(): void diff --git a/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php b/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php index e0c4b69c..c768ed82 100644 --- a/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php +++ b/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php @@ -1,5 +1,7 @@ , message: string}> + * @return \Iterator | string), message: string}> */ - public static function invalidResourceProvider(): array + public static function invalidResourceProvider(): \Iterator { - return [ - 'fragment' => [ - 'resources' => 'https://api.example.com/#section', - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - ], - 'relative URI' => [ - 'resources' => '/relative', - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - ], - 'urn URI' => [ - 'resources' => 'urn:example:resource', - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - ], - 'file URI' => [ - 'resources' => 'file:///etc/passwd', - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - ], - 'javascript URI' => [ - 'resources' => 'javascript:alert(1)', - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - ], - 'non-string' => [ - 'resources' => ['https://api.example.com/', 42], - 'message' => 'resource must be a non-empty absolute URI.', - ], + yield 'fragment' => [ + 'resources' => 'https://api.example.com/#section', + 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', + ]; + yield 'relative URI' => [ + 'resources' => '/relative', + 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', + ]; + yield 'urn URI' => [ + 'resources' => 'urn:example:resource', + 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', + ]; + yield 'file URI' => [ + 'resources' => 'file:///etc/passwd', + 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', + ]; + yield 'javascript URI' => [ + 'resources' => 'javascript:alert(1)', + 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', + ]; + yield 'non-string' => [ + 'resources' => ['https://api.example.com/', 42], + 'message' => 'resource must be a non-empty absolute URI.', ]; } } diff --git a/packages/auth/tests/Auth/Proofs/CodeTest.php b/packages/auth/tests/Auth/Proofs/CodeTest.php index 485ea336..73824164 100644 --- a/packages/auth/tests/Auth/Proofs/CodeTest.php +++ b/packages/auth/tests/Auth/Proofs/CodeTest.php @@ -1,11 +1,13 @@ code->generate(); $this->assertNotEmpty($proof); - $this->assertIsString($proof); - $this->assertEquals(6, \strlen($proof)); // Default code length - $this->assertMatchesRegularExpression('/^[0-9]{6}$/', $proof); + $this->assertSame(6, \strlen($proof)); // Default code length + $this->assertMatchesRegularExpression('/^\d{6}$/', $proof); } public function testHash(): void @@ -30,7 +31,6 @@ public function testHash(): void $hash = $this->code->hash($proof); $this->assertNotEmpty($hash); - $this->assertIsString($hash); } public function testVerify(): void @@ -47,26 +47,26 @@ public function testCustomLength(): void $code = new Code(8); $proof = $code->generate(); - $this->assertEquals(8, \strlen($proof)); - $this->assertMatchesRegularExpression('/^[0-9]{8}$/', $proof); + $this->assertSame(8, \strlen($proof)); + $this->assertMatchesRegularExpression('/^\d{8}$/', $proof); } public function testGetLength(): void { - $this->assertEquals(6, $this->code->getLength()); + $this->assertSame(6, $this->code->getLength()); $code = new Code(8); - $this->assertEquals(8, $code->getLength()); + $this->assertSame(8, $code->getLength()); } public function testSetLength(): void { $this->code->setLength(4); - $this->assertEquals(4, $this->code->getLength()); + $this->assertSame(4, $this->code->getLength()); $proof = $this->code->generate(); - $this->assertEquals(4, \strlen($proof)); - $this->assertMatchesRegularExpression('/^[0-9]{4}$/', $proof); + $this->assertSame(4, \strlen($proof)); + $this->assertMatchesRegularExpression('/^\d{4}$/', $proof); } public function testSetLengthInvalid(): void diff --git a/packages/auth/tests/Auth/Proofs/PasswordTest.php b/packages/auth/tests/Auth/Proofs/PasswordTest.php index b9a6c65b..5b969695 100644 --- a/packages/auth/tests/Auth/Proofs/PasswordTest.php +++ b/packages/auth/tests/Auth/Proofs/PasswordTest.php @@ -1,5 +1,7 @@ password = new Password(); // Test legacy constructor with explicit hashes - $this->bcrypt = new Bcrypt(); + new Bcrypt(); } public function testGenerate(): void @@ -32,8 +32,7 @@ public function testGenerate(): void $proof = $this->password->generate(); $this->assertNotEmpty($proof); - $this->assertIsString($proof); - $this->assertEquals(16, \strlen($proof)); // Default length + $this->assertSame(16, \strlen($proof)); // Default length $this->assertMatchesRegularExpression('/^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{}|;:,.<>?]+$/', $proof); } @@ -41,7 +40,7 @@ public function testGenerateWithCustomLength(): void { $this->password->setLength(20); $proof = $this->password->generate(); - $this->assertEquals(20, \strlen($proof)); + $this->assertSame(20, \strlen($proof)); } public function testGenerateWithCustomCharset(): void @@ -71,7 +70,6 @@ public function testHash(): void $hash = $this->password->hash($proof); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertStringStartsWith('$argon2id$', $hash); // Default is now argon2 } diff --git a/packages/auth/tests/Auth/Proofs/PhraseTest.php b/packages/auth/tests/Auth/Proofs/PhraseTest.php index 4964e6a1..308275d8 100644 --- a/packages/auth/tests/Auth/Proofs/PhraseTest.php +++ b/packages/auth/tests/Auth/Proofs/PhraseTest.php @@ -1,11 +1,13 @@ phrase->generate(); $this->assertNotEmpty($proof); - $this->assertIsString($proof); $this->assertStringContainsString(' ', $proof); // Should contain spaces between words $this->assertMatchesRegularExpression('/^[a-zA-Z\s]+$/', $proof); // Letters (both cases) and spaces } @@ -30,7 +31,6 @@ public function testHash(): void $hash = $this->phrase->hash($proof); $this->assertNotEmpty($hash); - $this->assertIsString($hash); $this->assertStringStartsWith('$argon2id$', $hash); } diff --git a/packages/auth/tests/Auth/Proofs/TokenTest.php b/packages/auth/tests/Auth/Proofs/TokenTest.php index ede8bdde..e1e270ec 100644 --- a/packages/auth/tests/Auth/Proofs/TokenTest.php +++ b/packages/auth/tests/Auth/Proofs/TokenTest.php @@ -1,12 +1,14 @@ token->generate(); $this->assertNotEmpty($proof); - $this->assertIsString($proof); - $this->assertEquals(32, \strlen($proof)); // Default token length + $this->assertSame(32, \strlen($proof)); // Default token length } public function testHash(): void @@ -34,8 +35,7 @@ public function testHash(): void $hash = $this->token->hash($proof); $this->assertNotEmpty($hash); - $this->assertIsString($hash); - $this->assertEquals(64, \strlen($hash)); // SHA-256 produces a 64-character hex string + $this->assertSame(64, \strlen($hash)); // SHA-256 produces a 64-character hex string $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash); // SHA-256 hex format } @@ -50,19 +50,19 @@ public function testVerify(): void public function testGetLength(): void { - $this->assertEquals(32, $this->token->getLength()); + $this->assertSame(32, $this->token->getLength()); $token = new Token(64); - $this->assertEquals(64, $token->getLength()); + $this->assertSame(64, $token->getLength()); } public function testSetLength(): void { $this->token->setLength(64); - $this->assertEquals(64, $this->token->getLength()); + $this->assertSame(64, $this->token->getLength()); $proof = $this->token->generate(); - $this->assertEquals(64, \strlen($proof)); + $this->assertSame(64, \strlen($proof)); } public function testSetLengthInvalid(): void diff --git a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php index 25cbc58f..1aa27373 100644 --- a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php +++ b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php @@ -1,5 +1,7 @@ privateKey, $this->publicKey] = AccessToken::generateKeyPair(); - $this->issuer = new AccessToken($this->privateKey, $this->publicKey, $this->iss); - $this->verifier = new Asymmetric($this->publicKey); + [$privateKey, $publicKey] = AccessToken::generateKeyPair(); + $this->issuer = new AccessToken($privateKey, $publicKey, $this->iss); + $this->verifier = new Asymmetric($publicKey); } public function testVerifiesIssuedToken(): void @@ -41,7 +39,7 @@ public function testVerifiesIssuedToken(): void public function testKeyIdMatchesIssuer(): void { // Issuer and verifier must agree on the "kid" for the same key. - $this->assertEquals($this->issuer->getKeyId(), $this->verifier->getKeyId()); + $this->assertSame($this->issuer->getKeyId(), $this->verifier->getKeyId()); } public function testIssuerCheckPasses(): void diff --git a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php index c46255bc..449b09fe 100644 --- a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php +++ b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php @@ -1,5 +1,7 @@ secret = RefreshToken::generateSecret(); - $this->issuer = new RefreshToken($this->secret, $this->iss); - $this->verifier = new Symmetric($this->secret); + $secret = RefreshToken::generateSecret(); + $this->issuer = new RefreshToken($secret, $this->iss); + $this->verifier = new Symmetric($secret); } public function testVerifiesIssuedToken(): void diff --git a/packages/auth/tests/StoreTest.php b/packages/auth/tests/StoreTest.php index 9b1c1b49..5d282a5b 100644 --- a/packages/auth/tests/StoreTest.php +++ b/packages/auth/tests/StoreTest.php @@ -1,11 +1,13 @@ assertNotFalse($decoded); - $this->assertEquals($encoded, base64_encode($decoded)); + $this->assertSame($encoded, base64_encode($decoded)); // Create a new store and decode the data $newStore = new Store(); From d755a0cbc093a46e773bf7487a9d8088cf1f3d5f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 16:56:38 +0530 Subject: [PATCH 6/8] (fix): harden token verification and align Password active hash Address review findings in the verifier and Password proof: - Require "exp" and always enforce "nbf"/"iat": allowExpired() now relaxes only the expiry check, so a not-yet-valid or future-issued token is still rejected, and a token with no "exp" no longer verifies forever. - Add setType() to pin the "typ" header (e.g. at+jwt), preventing one token kind from being accepted in place of another. - Reject JWS header/claims segments that decode to a JSON array rather than an object. - Password: keep the active hash aligned with the registry so a custom registry's algorithm is actually used and removeHash()'s current-hash guard matches. Adds regression tests for each. --- packages/auth/docs/jwt.md | 15 ++- packages/auth/src/Auth/Proofs/Password.php | 5 + packages/auth/src/Auth/Verifier.php | 104 ++++++++++++------ .../auth/tests/Auth/Proofs/PasswordTest.php | 18 +++ .../tests/Auth/Verifiers/AsymmetricTest.php | 80 +++++++++++++- 5 files changed, 182 insertions(+), 40 deletions(-) diff --git a/packages/auth/docs/jwt.md b/packages/auth/docs/jwt.md index 7de73ea7..2a95f673 100644 --- a/packages/auth/docs/jwt.md +++ b/packages/auth/docs/jwt.md @@ -126,6 +126,13 @@ signature is checked first, then the `alg` header, then whatever claim expectations you configure. `verify()` returns the decoded claims or throws a `VerificationException`. +By default a bounded lifetime is enforced: `exp` is **required** and must be in +the future, and `nbf`/`iat` are rejected if the token isn't valid yet or claims +a future issuance. The issuer, audience and type checks are opt-in — they only +run once you call `setIssuer()`, `setAudience()` or `setType()`. Use `setType()` +to pin the `typ` header (e.g. `at+jwt`) so one token kind can't be accepted in +place of another. + ```php setIssuer('https://example.com/v1/oauth2/project') ->setAudience('https://example.com/v1/project') - ->setLeeway(30); // tolerate 30s of clock skew + ->setType('at+jwt') // require an RFC 9068 access token + ->setLeeway(30); // tolerate 30s of clock skew try { $claims = $verifier->verify($accessToken); } catch (VerificationException) { - // malformed, bad signature, wrong alg, expired, or a claim mismatch + // malformed, bad signature, wrong alg/type, expired, or a claim mismatch } ``` For an OpenID Connect `id_token_hint` (which must be accepted even after it -expires), relax the time checks with `allowExpired()`: +expires), relax only the expiry check with `allowExpired()` (`nbf`/`iat` are +still enforced): ```php $claims = (new Asymmetric($publicKey)) diff --git a/packages/auth/src/Auth/Proofs/Password.php b/packages/auth/src/Auth/Proofs/Password.php index f9477e56..3318d0fd 100644 --- a/packages/auth/src/Auth/Proofs/Password.php +++ b/packages/auth/src/Auth/Proofs/Password.php @@ -60,6 +60,11 @@ public function __construct(array $hashes = []) } $this->hashes = $hashes; + + // Keep the active hash aligned with the registry so generate()/hash() + // use a registered algorithm (Argon2 by default, otherwise the first + // registered one) and removeHash()'s current-hash guard can match it. + $this->hash = $this->hashes[self::ARGON2] ?? array_values($this->hashes)[0]; } /** diff --git a/packages/auth/src/Auth/Verifier.php b/packages/auth/src/Auth/Verifier.php index 5abc0204..72f13608 100644 --- a/packages/auth/src/Auth/Verifier.php +++ b/packages/auth/src/Auth/Verifier.php @@ -18,11 +18,12 @@ * live one level down: {@see \Utopia\Auth\Verifiers\Asymmetric} (RS256) and * {@see \Utopia\Auth\Verifiers\Symmetric} (HS256). * - * Expected claim values are opt-in and configured fluently: a check only runs - * once you supply what to compare against ({@see setIssuer()}, - * {@see setAudience()}). Time validation is on by default and can be relaxed - * with {@see allowExpired()} (e.g. an OIDC `id_token_hint`) or - * {@see setLeeway()} for clock skew. + * Expected values are opt-in and configured fluently: the issuer, audience and + * type are only checked once you supply them ({@see setIssuer()}, + * {@see setAudience()}, {@see setType()}). Expiry is enforced by default — + * "exp" is required and must be in the future — while "nbf"/"iat" are always + * enforced when present; {@see allowExpired()} relaxes only the expiry check + * and {@see setLeeway()} tolerates clock skew. */ abstract class Verifier { @@ -40,7 +41,14 @@ abstract class Verifier protected ?array $audience = null; /** - * Whether the time-based claims ("exp"/"nbf"/"iat") are enforced. + * Expected "typ" header (e.g. "at+jwt", "JWT"). When null the type is not + * checked. + */ + protected ?string $type = null; + + /** + * Whether "exp" is required and enforced. "nbf" and "iat" are always + * enforced regardless; this flag only relaxes expiry (see {@see allowExpired()}). */ protected bool $validateTime = true; @@ -82,9 +90,10 @@ public function setAudience(string|array $audience): static } /** - * Accept tokens whose lifetime has lapsed by skipping the time-based - * claims. Useful for an OIDC `id_token_hint`, where the spec requires the - * OP to accept an expired hint for a current or recent session. + * Accept tokens whose lifetime has lapsed by skipping the "exp" check (and + * its required-presence rule). "nbf" and "iat" are still enforced. Useful + * for an OIDC `id_token_hint`, where the spec requires the OP to accept an + * expired hint for a current or recent session. */ public function allowExpired(bool $allow = true): static { @@ -93,6 +102,18 @@ public function allowExpired(bool $allow = true): static return $this; } + /** + * Require the token's "typ" header to equal $type (e.g. "at+jwt" for an + * RFC 9068 access token), so one token type cannot be accepted in place of + * another even when issuer and audience match. + */ + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + /** * Allow up to $seconds of clock skew when checking the time-based claims. */ @@ -141,6 +162,10 @@ public function verify(string $token): array throw new VerificationException('Unexpected token algorithm'); } + if ($this->type !== null && ($header[Header::Type->value] ?? null) !== $this->type) { + throw new VerificationException('Unexpected token type'); + } + if (!$this->verifySignature("{$encodedHeader}.{$encodedClaims}", $signature)) { throw new VerificationException('Signature verification failed'); } @@ -161,35 +186,41 @@ protected function validateClaims(array $claims): void { $now = time(); - if ($this->validateTime) { - $exp = $claims[Claim::Expiration->value] ?? null; - if ($exp !== null) { - if (!is_numeric($exp)) { - throw new VerificationException('Invalid "exp" claim'); - } - if ($now >= (int) $exp + $this->leeway) { - throw new VerificationException('Token has expired'); - } + // "nbf"/"iat" bound when a token *becomes* valid; they are always + // enforced, so a token that is not valid yet or claims a future + // issuance is rejected even when expiry is relaxed via allowExpired(). + $nbf = $claims[Claim::NotBefore->value] ?? null; + if ($nbf !== null) { + if (!is_numeric($nbf)) { + throw new VerificationException('Invalid "nbf" claim'); } + if ($now + $this->leeway < (int) $nbf) { + throw new VerificationException('Token is not yet valid'); + } + } - $nbf = $claims[Claim::NotBefore->value] ?? null; - if ($nbf !== null) { - if (!is_numeric($nbf)) { - throw new VerificationException('Invalid "nbf" claim'); - } - if ($now + $this->leeway < (int) $nbf) { - throw new VerificationException('Token is not yet valid'); - } + $iat = $claims[Claim::IssuedAt->value] ?? null; + if ($iat !== null) { + if (!is_numeric($iat)) { + throw new VerificationException('Invalid "iat" claim'); + } + if ($now + $this->leeway < (int) $iat) { + throw new VerificationException('Token was issued in the future'); } + } - $iat = $claims[Claim::IssuedAt->value] ?? null; - if ($iat !== null) { - if (!is_numeric($iat)) { - throw new VerificationException('Invalid "iat" claim'); - } - if ($now + $this->leeway < (int) $iat) { - throw new VerificationException('Token was issued in the future'); - } + // These are bounded-lifetime bearer tokens, so "exp" is required and + // must be in the future — unless relaxed via allowExpired(). + if ($this->validateTime) { + $exp = $claims[Claim::Expiration->value] ?? null; + if ($exp === null) { + throw new VerificationException('Token is missing the "exp" claim'); + } + if (!is_numeric($exp)) { + throw new VerificationException('Invalid "exp" claim'); + } + if ($now >= (int) $exp + $this->leeway) { + throw new VerificationException('Token has expired'); } } @@ -242,7 +273,10 @@ private function decodeSegment(string $segment, string $name): array throw new VerificationException("{$label} is not valid JSON"); } - if (!\is_array($data)) { + // json_decode(..., true) maps both JSON objects and JSON arrays to PHP + // arrays; a populated list means the segment was a JSON array, which is + // not a valid JWT header/claims object. + if (!\is_array($data) || (array_is_list($data) && $data !== [])) { throw new VerificationException("{$label} must be a JSON object"); } diff --git a/packages/auth/tests/Auth/Proofs/PasswordTest.php b/packages/auth/tests/Auth/Proofs/PasswordTest.php index 5b969695..94d69efe 100644 --- a/packages/auth/tests/Auth/Proofs/PasswordTest.php +++ b/packages/auth/tests/Auth/Proofs/PasswordTest.php @@ -198,4 +198,22 @@ public function testCreateHashWithInvalidOptions(): void ]); $this->assertInstanceOf(Bcrypt::class, $hash); } + + public function testActiveHashComesFromRegistry(): void + { + // A custom registry without Argon2 must drive the active hash instead + // of silently falling back to an unregistered Argon2 instance. + $password = new Password([Password::BCRYPT => new Bcrypt()]); + + $this->assertInstanceOf(Bcrypt::class, $password->getHash()); + } + + public function testRemoveCurrentDefaultHashIsGuarded(): void + { + // The default active hash is the registry's Argon2 instance, so the + // current-hash guard fires when removing it. + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Cannot remove current hash'); + $this->password->removeHash(Password::ARGON2); + } } diff --git a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php index 1aa27373..9c20a254 100644 --- a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php +++ b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php @@ -12,6 +12,8 @@ final class AsymmetricTest extends TestCase { + protected string $privateKey; + protected AccessToken $issuer; protected Asymmetric $verifier; @@ -20,11 +22,28 @@ final class AsymmetricTest extends TestCase protected function setUp(): void { - [$privateKey, $publicKey] = AccessToken::generateKeyPair(); - $this->issuer = new AccessToken($privateKey, $publicKey, $this->iss); + [$this->privateKey, $publicKey] = AccessToken::generateKeyPair(); + $this->issuer = new AccessToken($this->privateKey, $publicKey, $this->iss); $this->verifier = new Asymmetric($publicKey); } + /** + * Hand-sign an RS256 JWS so tests can craft tokens the issuers never + * produce (e.g. without "exp", or with a non-object segment). + * + * @param array $claims + * @param array $header + */ + private function signRs256(array $claims, array $header = ['typ' => 'at+jwt', 'alg' => 'RS256']): string + { + $encode = fn(mixed $part): string => rtrim(strtr(base64_encode((string) json_encode($part)), '+/', '-_'), '='); + + $signingInput = $encode($header) . '.' . $encode($claims); + openssl_sign($signingInput, $signature, $this->privateKey, OPENSSL_ALGO_SHA256); + + return $signingInput . '.' . rtrim(strtr(base64_encode((string) $signature), '+/', '-_'), '='); + } + public function testVerifiesIssuedToken(): void { $token = $this->issuer->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read', 'write']); @@ -148,4 +167,61 @@ public function testMalformedTokenRejected(): void $this->expectExceptionMessage('Token must have three segments'); $this->verifier->verify('not-a-jwt'); } + + public function testMissingExpirationRejected(): void + { + // A signed token with no "exp" must not verify forever. + $token = $this->signRs256(['sub' => 'u']); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token is missing the "exp" claim'); + $this->verifier->verify($token); + } + + public function testNotYetValidRejectedEvenWhenExpiredAllowed(): void + { + // allowExpired() relaxes only "exp"; a future "nbf" is still rejected. + $token = $this->signRs256(['exp' => time() + 3600, 'nbf' => time() + 3600]); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token is not yet valid'); + $this->verifier->allowExpired()->verify($token); + } + + public function testFutureIssuedAtRejected(): void + { + $token = $this->signRs256(['exp' => time() + 3600, 'iat' => time() + 3600]); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Token was issued in the future'); + $this->verifier->verify($token); + } + + public function testTypeMismatchRejected(): void + { + // The issuer mints "at+jwt"; pinning a different type must reject it. + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Unexpected token type'); + $this->verifier->setType('JWT')->verify($token); + } + + public function testTypeMatchAccepted(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $claims = $this->verifier->setType('at+jwt')->verify($token); + + $this->assertSame('u', $claims['sub']); + } + + public function testNonObjectClaimsRejected(): void + { + // A JSON array as the claims segment is not a valid JWT payload. + $token = $this->signRs256([1, 2, 3]); + + $this->expectException(VerificationException::class); + $this->expectExceptionMessage('Claims must be a JSON object'); + $this->verifier->verify($token); + } } From b4719db34bc7e2c04ef89841c72056c6c38273d4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 22 Jun 2026 17:00:47 +0530 Subject: [PATCH 7/8] (chore): declare strict_types in PHPass to satisfy rector on php 8.4 --- packages/auth/src/Auth/Hashes/PHPass.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/auth/src/Auth/Hashes/PHPass.php b/packages/auth/src/Auth/Hashes/PHPass.php index 329048bd..a688317d 100644 --- a/packages/auth/src/Auth/Hashes/PHPass.php +++ b/packages/auth/src/Auth/Hashes/PHPass.php @@ -1,5 +1,7 @@ Date: Mon, 22 Jun 2026 17:14:31 +0530 Subject: [PATCH 8/8] (refactor): make the verifier immutable via constructor params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the verifier's fluent setters (setIssuer/setAudience/setType/ allowExpired/setLeeway) with readonly constructor parameters passed via named arguments. Mutable fluent setters on a shared instance are a coroutine footgun — another coroutine can flip the expectations mid-verification — so the configuration is now set once and held read-only. Concrete verifiers take the key plus the optional expectations and forward them to the base constructor; tests and docs use the named-argument form. --- packages/auth/docs/jwt.md | 39 +++--- packages/auth/src/Auth/Verifier.php | 126 +++++------------- .../auth/src/Auth/Verifiers/Asymmetric.php | 17 ++- .../auth/src/Auth/Verifiers/Symmetric.php | 17 ++- .../tests/Auth/Verifiers/AsymmetricTest.php | 26 ++-- .../tests/Auth/Verifiers/SymmetricTest.php | 27 ++-- 6 files changed, 113 insertions(+), 139 deletions(-) diff --git a/packages/auth/docs/jwt.md b/packages/auth/docs/jwt.md index 2a95f673..0fd22fcd 100644 --- a/packages/auth/docs/jwt.md +++ b/packages/auth/docs/jwt.md @@ -122,16 +122,17 @@ $serialized = $resources->toArray(); ## Verifying tokens Verify a token minted by one of the issuers (or any compliant JWS). The -signature is checked first, then the `alg` header, then whatever claim -expectations you configure. `verify()` returns the decoded claims or throws a +signature is checked first, then the `alg` header, then the claim expectations +you pass to the constructor. `verify()` returns the decoded claims or throws a `VerificationException`. -By default a bounded lifetime is enforced: `exp` is **required** and must be in -the future, and `nbf`/`iat` are rejected if the token isn't valid yet or claims -a future issuance. The issuer, audience and type checks are opt-in — they only -run once you call `setIssuer()`, `setAudience()` or `setType()`. Use `setType()` -to pin the `typ` header (e.g. `at+jwt`) so one token kind can't be accepted in -place of another. +Expectations are passed at construction (not fluent setters) and held +read-only, so a verifier instance is immutable and safe to share across +coroutines. By default a bounded lifetime is enforced: `exp` is **required** and +must be in the future, and `nbf`/`iat` are rejected if the token isn't valid yet +or claims a future issuance. The `issuer`, `audience` and `type` checks are +opt-in — supply `type` to pin the `typ` header (e.g. `at+jwt`) so one token kind +can't be accepted in place of another. ```php setIssuer('https://example.com/v1/oauth2/project') - ->setAudience('https://example.com/v1/project') - ->setType('at+jwt') // require an RFC 9068 access token - ->setLeeway(30); // tolerate 30s of clock skew +$verifier = new Asymmetric( + $publicKey, + issuer: 'https://example.com/v1/oauth2/project', + audience: 'https://example.com/v1/project', + type: 'at+jwt', // require an RFC 9068 access token + leeway: 30, // tolerate 30s of clock skew +); try { $claims = $verifier->verify($accessToken); @@ -154,13 +157,11 @@ try { ``` For an OpenID Connect `id_token_hint` (which must be accepted even after it -expires), relax only the expiry check with `allowExpired()` (`nbf`/`iat` are +expires), relax only the expiry check with `allowExpired: true` (`nbf`/`iat` are still enforced): ```php -$claims = (new Asymmetric($publicKey)) - ->setIssuer($issuer) - ->allowExpired() +$claims = (new Asymmetric($publicKey, issuer: $issuer, allowExpired: true)) ->verify($idToken); ``` @@ -170,9 +171,7 @@ secret: ```php use Utopia\Auth\Verifiers\Symmetric; -$claims = (new Symmetric($secret)) - ->setIssuer($issuer) - ->setAudience('https://example.com/token') +$claims = (new Symmetric($secret, issuer: $issuer, audience: 'https://example.com/token')) ->verify($refreshToken); ``` diff --git a/packages/auth/src/Auth/Verifier.php b/packages/auth/src/Auth/Verifier.php index 72f13608..90cbbb7a 100644 --- a/packages/auth/src/Auth/Verifier.php +++ b/packages/auth/src/Auth/Verifier.php @@ -18,44 +18,53 @@ * live one level down: {@see \Utopia\Auth\Verifiers\Asymmetric} (RS256) and * {@see \Utopia\Auth\Verifiers\Symmetric} (HS256). * - * Expected values are opt-in and configured fluently: the issuer, audience and - * type are only checked once you supply them ({@see setIssuer()}, - * {@see setAudience()}, {@see setType()}). Expiry is enforced by default — - * "exp" is required and must be in the future — while "nbf"/"iat" are always - * enforced when present; {@see allowExpired()} relaxes only the expiry check - * and {@see setLeeway()} tolerates clock skew. + * Expectations are passed to the constructor (not fluent setters) and held + * read-only, so a shared instance is coroutine-safe — its issuer, audience or + * type cannot be flipped mid-verification. The issuer, audience and type are + * only checked once supplied. Expiry is enforced by default — "exp" is required + * and must be in the future — while "nbf"/"iat" are always enforced when + * present; `$allowExpired` relaxes only the expiry check and `$leeway` + * tolerates clock skew. */ abstract class Verifier { - /** - * Expected "iss" claim. When null the issuer is not checked. - */ - protected ?string $issuer = null; - /** * Acceptable "aud" values. A token passes when any of these appears in its * audience. When null the audience is not checked. * * @var array|null */ - protected ?array $audience = null; - - /** - * Expected "typ" header (e.g. "at+jwt", "JWT"). When null the type is not - * checked. - */ - protected ?string $type = null; + protected readonly ?array $audience; /** - * Whether "exp" is required and enforced. "nbf" and "iat" are always - * enforced regardless; this flag only relaxes expiry (see {@see allowExpired()}). - */ - protected bool $validateTime = true; + * Configuration is immutable: passed once at construction so a shared + * instance cannot have its expectations flipped mid-verification. + * + * @param string|null $issuer Required "iss" claim; not checked when null. + * @param string|array|null $audience Acceptable "aud" value(s); a token passes when any appears in its audience. Not checked when null. + * @param string|null $type Required "typ" header (e.g. "at+jwt"); not checked when null, so one token kind cannot be accepted in place of another. + * @param bool $allowExpired When true, skip the "exp" check and its required-presence rule (e.g. an OIDC `id_token_hint`); "nbf"/"iat" are still enforced. + * @param int $leeway Clock-skew tolerance in seconds for the time-based claims. + * + * @throws \InvalidArgumentException When the leeway is negative. + */ + public function __construct( + protected readonly ?string $issuer = null, + string|array|null $audience = null, + protected readonly ?string $type = null, + protected readonly bool $allowExpired = false, + protected readonly int $leeway = 0, + ) { + if ($leeway < 0) { + throw new \InvalidArgumentException('Leeway cannot be negative'); + } - /** - * Clock-skew tolerance in seconds applied to the time-based claims. - */ - protected int $leeway = 0; + $this->audience = match (true) { + $audience === null => null, + \is_array($audience) => array_values($audience), + default => [$audience], + }; + } /** * The JWS "alg" header the token must carry (e.g. "RS256", "HS256"). @@ -67,67 +76,6 @@ abstract protected function getAlgorithm(): string; */ abstract protected function verifySignature(string $signingInput, string $signature): bool; - /** - * Require the token's "iss" claim to equal $issuer. - */ - public function setIssuer(string $issuer): static - { - $this->issuer = $issuer; - - return $this; - } - - /** - * Require the token's "aud" claim to contain at least one of these values. - * - * @param string|array $audience - */ - public function setAudience(string|array $audience): static - { - $this->audience = \is_array($audience) ? array_values($audience) : [$audience]; - - return $this; - } - - /** - * Accept tokens whose lifetime has lapsed by skipping the "exp" check (and - * its required-presence rule). "nbf" and "iat" are still enforced. Useful - * for an OIDC `id_token_hint`, where the spec requires the OP to accept an - * expired hint for a current or recent session. - */ - public function allowExpired(bool $allow = true): static - { - $this->validateTime = !$allow; - - return $this; - } - - /** - * Require the token's "typ" header to equal $type (e.g. "at+jwt" for an - * RFC 9068 access token), so one token type cannot be accepted in place of - * another even when issuer and audience match. - */ - public function setType(string $type): static - { - $this->type = $type; - - return $this; - } - - /** - * Allow up to $seconds of clock skew when checking the time-based claims. - */ - public function setLeeway(int $seconds): static - { - if ($seconds < 0) { - throw new \InvalidArgumentException('Leeway cannot be negative'); - } - - $this->leeway = $seconds; - - return $this; - } - /** * Verify a compact JWS and return its claims. * @@ -210,8 +158,8 @@ protected function validateClaims(array $claims): void } // These are bounded-lifetime bearer tokens, so "exp" is required and - // must be in the future — unless relaxed via allowExpired(). - if ($this->validateTime) { + // must be in the future — unless relaxed via $allowExpired. + if (!$this->allowExpired) { $exp = $claims[Claim::Expiration->value] ?? null; if ($exp === null) { throw new VerificationException('Token is missing the "exp" claim'); diff --git a/packages/auth/src/Auth/Verifiers/Asymmetric.php b/packages/auth/src/Auth/Verifiers/Asymmetric.php index 495072b2..41c5cfab 100644 --- a/packages/auth/src/Auth/Verifiers/Asymmetric.php +++ b/packages/auth/src/Auth/Verifiers/Asymmetric.php @@ -15,14 +15,27 @@ class Asymmetric extends Verifier { /** * @param string $publicKey PEM-encoded RSA public key used to verify the signature. + * @param string|null $issuer Required "iss" claim; not checked when null. + * @param string|array|null $audience Acceptable "aud" value(s); not checked when null. + * @param string|null $type Required "typ" header (e.g. "at+jwt"); not checked when null. + * @param bool $allowExpired Skip the "exp" check when true; "nbf"/"iat" stay enforced. + * @param int $leeway Clock-skew tolerance in seconds. * * @throws \Exception When the public key is missing. */ - public function __construct(protected readonly string $publicKey) - { + public function __construct( + protected readonly string $publicKey, + ?string $issuer = null, + string|array|null $audience = null, + ?string $type = null, + bool $allowExpired = false, + int $leeway = 0, + ) { if ($publicKey === '' || $publicKey === '0') { throw new \Exception('A public key is required'); } + + parent::__construct($issuer, $audience, $type, $allowExpired, $leeway); } /** diff --git a/packages/auth/src/Auth/Verifiers/Symmetric.php b/packages/auth/src/Auth/Verifiers/Symmetric.php index da34e738..b2e8933f 100644 --- a/packages/auth/src/Auth/Verifiers/Symmetric.php +++ b/packages/auth/src/Auth/Verifiers/Symmetric.php @@ -18,14 +18,27 @@ class Symmetric extends Verifier { /** * @param string $secret The shared signing secret used to verify the signature. + * @param string|null $issuer Required "iss" claim; not checked when null. + * @param string|array|null $audience Acceptable "aud" value(s); not checked when null. + * @param string|null $type Required "typ" header (e.g. "JWT"); not checked when null. + * @param bool $allowExpired Skip the "exp" check when true; "nbf"/"iat" stay enforced. + * @param int $leeway Clock-skew tolerance in seconds. * * @throws \Exception When the secret is missing. */ - public function __construct(protected readonly string $secret) - { + public function __construct( + protected readonly string $secret, + ?string $issuer = null, + string|array|null $audience = null, + ?string $type = null, + bool $allowExpired = false, + int $leeway = 0, + ) { if ($secret === '' || $secret === '0') { throw new \Exception('A signing secret is required'); } + + parent::__construct($issuer, $audience, $type, $allowExpired, $leeway); } protected function getAlgorithm(): string diff --git a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php index 9c20a254..b718f507 100644 --- a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php +++ b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php @@ -14,6 +14,8 @@ final class AsymmetricTest extends TestCase { protected string $privateKey; + protected string $publicKey; + protected AccessToken $issuer; protected Asymmetric $verifier; @@ -22,9 +24,9 @@ final class AsymmetricTest extends TestCase protected function setUp(): void { - [$this->privateKey, $publicKey] = AccessToken::generateKeyPair(); - $this->issuer = new AccessToken($this->privateKey, $publicKey, $this->iss); - $this->verifier = new Asymmetric($publicKey); + [$this->privateKey, $this->publicKey] = AccessToken::generateKeyPair(); + $this->issuer = new AccessToken($this->privateKey, $this->publicKey, $this->iss); + $this->verifier = new Asymmetric($this->publicKey); } /** @@ -64,7 +66,7 @@ public function testKeyIdMatchesIssuer(): void public function testIssuerCheckPasses(): void { $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); - $claims = $this->verifier->setIssuer($this->iss)->verify($token); + $claims = (new Asymmetric($this->publicKey, issuer: $this->iss))->verify($token); $this->assertEquals($this->iss, $claims['iss']); } @@ -75,13 +77,13 @@ public function testIssuerMismatchRejected(): void $this->expectException(VerificationException::class); $this->expectExceptionMessage('Unexpected token issuer'); - $this->verifier->setIssuer('https://evil.example.com')->verify($token); + (new Asymmetric($this->publicKey, issuer: 'https://evil.example.com'))->verify($token); } public function testAudienceMembershipPasses(): void { $token = $this->issuer->issue('u', ['https://a.example.com', 'https://b.example.com'], 'c', 1000, 3600); - $claims = $this->verifier->setAudience('https://b.example.com')->verify($token); + $claims = (new Asymmetric($this->publicKey, audience: 'https://b.example.com'))->verify($token); $this->assertContains('https://b.example.com', $claims['aud']); } @@ -92,7 +94,7 @@ public function testAudienceMismatchRejected(): void $this->expectException(VerificationException::class); $this->expectExceptionMessage('Unexpected token audience'); - $this->verifier->setAudience('https://other.example.com')->verify($token); + (new Asymmetric($this->publicKey, audience: 'https://other.example.com'))->verify($token); } public function testExpiredTokenRejected(): void @@ -108,7 +110,7 @@ public function testExpiredTokenRejected(): void public function testExpiredTokenAcceptedWhenAllowed(): void { $token = $this->issuer->issue('u', ['aud'], 'c', 1000, -3600); - $claims = $this->verifier->allowExpired()->verify($token); + $claims = (new Asymmetric($this->publicKey, allowExpired: true))->verify($token); $this->assertEquals('u', $claims['sub']); } @@ -180,12 +182,12 @@ public function testMissingExpirationRejected(): void public function testNotYetValidRejectedEvenWhenExpiredAllowed(): void { - // allowExpired() relaxes only "exp"; a future "nbf" is still rejected. + // allowExpired relaxes only "exp"; a future "nbf" is still rejected. $token = $this->signRs256(['exp' => time() + 3600, 'nbf' => time() + 3600]); $this->expectException(VerificationException::class); $this->expectExceptionMessage('Token is not yet valid'); - $this->verifier->allowExpired()->verify($token); + (new Asymmetric($this->publicKey, allowExpired: true))->verify($token); } public function testFutureIssuedAtRejected(): void @@ -204,13 +206,13 @@ public function testTypeMismatchRejected(): void $this->expectException(VerificationException::class); $this->expectExceptionMessage('Unexpected token type'); - $this->verifier->setType('JWT')->verify($token); + (new Asymmetric($this->publicKey, type: 'JWT'))->verify($token); } public function testTypeMatchAccepted(): void { $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); - $claims = $this->verifier->setType('at+jwt')->verify($token); + $claims = (new Asymmetric($this->publicKey, type: 'at+jwt'))->verify($token); $this->assertSame('u', $claims['sub']); } diff --git a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php index 449b09fe..4a3d42ac 100644 --- a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php +++ b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php @@ -11,6 +11,8 @@ final class SymmetricTest extends TestCase { + protected string $secret; + protected RefreshToken $issuer; protected Symmetric $verifier; @@ -19,22 +21,19 @@ final class SymmetricTest extends TestCase protected function setUp(): void { - $secret = RefreshToken::generateSecret(); - $this->issuer = new RefreshToken($secret, $this->iss); - $this->verifier = new Symmetric($secret); + $this->secret = RefreshToken::generateSecret(); + $this->issuer = new RefreshToken($this->secret, $this->iss); + $this->verifier = new Symmetric($this->secret); } public function testVerifiesIssuedToken(): void { $token = $this->issuer->issue('user-123', 'https://example.com/token', 'client-abc', 3600, ['offline_access']); - $claims = $this->verifier - ->setIssuer($this->iss) - ->setAudience('https://example.com/token') - ->verify($token); - - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('client-abc', $claims['client_id']); - $this->assertEquals('offline_access', $claims['scope']); + $claims = (new Symmetric($this->secret, issuer: $this->iss, audience: 'https://example.com/token'))->verify($token); + + $this->assertSame('user-123', $claims['sub']); + $this->assertSame('client-abc', $claims['client_id']); + $this->assertSame('offline_access', $claims['scope']); } public function testWrongSecretRejected(): void @@ -61,15 +60,15 @@ public function testAudienceMismatchRejected(): void $this->expectException(VerificationException::class); $this->expectExceptionMessage('Unexpected token audience'); - $this->verifier->setAudience('other')->verify($token); + (new Symmetric($this->secret, audience: 'other'))->verify($token); } public function testLeewayAllowsRecentlyExpired(): void { // Expired 10 seconds ago, but a 60s leeway tolerates the skew. $token = $this->issuer->issue('u', 'aud', 'c', -10); - $claims = $this->verifier->setLeeway(60)->verify($token); + $claims = (new Symmetric($this->secret, leeway: 60))->verify($token); - $this->assertEquals('u', $claims['sub']); + $this->assertSame('u', $claims['sub']); } }