diff --git a/packages/auth/README.md b/packages/auth/README.md index fb70a9cab..bd02881dc 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -3,289 +3,50 @@ > [!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) ![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 +56,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 35f9fa359..29aba30d2 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 74df09a91..c08a5e60c 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 000000000..687316b48 --- /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 000000000..0fd22fcd2 --- /dev/null +++ b/packages/auth/docs/jwt.md @@ -0,0 +1,204 @@ +# 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 the claim expectations +you pass to the constructor. `verify()` returns the decoded claims or throws a +`VerificationException`. + +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 +verify($accessToken); +} catch (VerificationException) { + // 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 only the expiry check with `allowExpired: true` (`nbf`/`iat` are +still enforced): + +```php +$claims = (new Asymmetric($publicKey, issuer: $issuer, allowExpired: true)) + ->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, issuer: $issuer, audience: '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`). diff --git a/packages/auth/docs/proofs.md b/packages/auth/docs/proofs.md new file mode 100644 index 000000000..3958eb888 --- /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 000000000..e85acd8b0 --- /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/phpstan.neon b/packages/auth/phpstan.neon new file mode 100644 index 000000000..46b0f4776 --- /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 000000000..77a88b28c --- /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 new file mode 100644 index 000000000..1b7eff09d --- /dev/null +++ b/packages/auth/src/Auth/Enums/Claim.php @@ -0,0 +1,54 @@ +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 +145,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 +219,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 +245,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 50fbc465a..be8652331 100644 --- a/packages/auth/src/Auth/Hashes/Plaintext.php +++ b/packages/auth/src/Auth/Hashes/Plaintext.php @@ -1,5 +1,7 @@ ". - */ - 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)) { + if ($issuer === '' || $issuer === '0') { throw new \Exception('An issuer is required'); } - - $this->issuer = $issuer; } /** @@ -73,10 +69,11 @@ protected function getHeaders(): array */ protected function sign(array $claims): string { - $header = array_merge([ - 'typ' => $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 a90afc0cb..60a607e1e 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; /** @@ -15,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)) { + if ($privateKey === '' || $privateKey === '0' || ($publicKey === '' || $publicKey === '0')) { throw new \Exception('Both a private and a public key are required'); } - - $this->privateKey = $privateKey; - $this->publicKey = $publicKey; - $this->keyId = $keyId; } /** @@ -121,7 +102,7 @@ private static function exportPublicKey(\OpenSSLAsymmetricKey $resource): string */ public function getKeyId(): string { - return $this->keyId ??= self::deriveKeyId($this->getModulus()); + return $this->keyId ??= $this->deriveKeyId($this->getModulus()); } /** @@ -150,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']), ]; @@ -168,7 +149,7 @@ protected function getAlgorithm(): string */ protected function getHeaders(): array { - return ['kid' => $this->getKeyId()]; + return [Header::KeyId->value => $this->getKeyId()]; } /** @@ -193,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 0c053bdc0..45c658fe1 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php @@ -1,7 +1,10 @@ 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); + if ($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 04ec25aef..2d958aa11 100644 --- a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php +++ b/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php @@ -1,7 +1,10 @@ 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; + if (!\in_array($nonce, [null, '', '0'], true)) { + $claims[Claim::Nonce->value] = $nonce; } - if (!empty($accessToken)) { - $claims['at_hash'] = $this->leftHalfHash($accessToken); + if (!\in_array($accessToken, [null, '', '0'], true)) { + $claims[Claim::AccessTokenHash->value] = $this->leftHalfHash($accessToken); } - if (!empty($code)) { - $claims['c_hash'] = $this->leftHalfHash($code); + if (!\in_array($code, [null, '', '0'], true)) { + $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 6cbb5c0d6..cf5f078da 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; /** @@ -14,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)) { + if ($secret === '' || $secret === '0') { throw new \Exception('A signing secret is required'); } - - $this->secret = $secret; - $this->keyId = $keyId; } /** @@ -77,7 +65,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 a934754fe..2af278c8f 100644 --- a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php +++ b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php @@ -1,7 +1,10 @@ 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); + if ($scopes !== []) { + $claims[Claim::Scope->value] = implode(' ', $scopes); } return $this->sign($claims); diff --git a/packages/auth/src/Auth/OAuth2/InvalidResourceException.php b/packages/auth/src/Auth/OAuth2/InvalidResourceException.php index d73e38665..10d8cdde5 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 f3948eb2e..eba80fed0 100644 --- a/packages/auth/src/Auth/Proof.php +++ b/packages/auth/src/Auth/Proof.php @@ -1,17 +1,14 @@ hash = new Argon2(); - } + 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 df5644847..4933982a0 100644 --- a/packages/auth/src/Auth/Proofs/Code.php +++ b/packages/auth/src/Auth/Proofs/Code.php @@ -1,5 +1,7 @@ hashes = $hashes; - $this->hash = new Argon2(); // Set the first hash as the default one + + // 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/Proofs/Phrase.php b/packages/auth/src/Auth/Proofs/Phrase.php index d1c034d18..041f8d374 100644 --- a/packages/auth/src/Auth/Proofs/Phrase.php +++ b/packages/auth/src/Auth/Proofs/Phrase.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 new file mode 100644 index 000000000..90cbbb7a3 --- /dev/null +++ b/packages/auth/src/Auth/Verifier.php @@ -0,0 +1,243 @@ +|null + */ + protected readonly ?array $audience; + + /** + * 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'); + } + + $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"). + */ + abstract protected function getAlgorithm(): string; + + /** + * Check the raw (binary) signature against the signing input. + */ + abstract protected function verifySignature(string $signingInput, string $signature): bool; + + /** + * 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->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'); + } + + $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(); + + // "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'); + } + } + + $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->allowExpired) { + $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'); + } + } + + 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"); + } + + // 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"); + } + + /** @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 000000000..41c5cfab6 --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/Asymmetric.php @@ -0,0 +1,95 @@ +|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, + ?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); + } + + /** + * 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 000000000..b2e8933fc --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/Symmetric.php @@ -0,0 +1,55 @@ +|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, + ?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 + { + 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 000000000..52c199983 --- /dev/null +++ b/packages/auth/src/Auth/Verifiers/VerificationException.php @@ -0,0 +1,12 @@ +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 7b1ec62e9..6e6a7a3a0 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 3c1ee6b79..ee29bb029 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 bbb759fa7..93a6f1a42 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 1afb7e887..350d456ad 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 eabcccc08..e49a311f5 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 16788c86d..5a65eb34a 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 a03a02d24..dd146e622 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 65ec3f466..a20460f30 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 543d97129..0407da946 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 2aca8445b..9bd0d8a72 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 a4f23ab54..a0416300b 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 e0c4b69c0..c768ed82f 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 485ea3363..738241646 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 b9a6c65ba..94d69efe6 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 } @@ -200,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/Proofs/PhraseTest.php b/packages/auth/tests/Auth/Proofs/PhraseTest.php index 4964e6a16..308275d89 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 ede8bdde4..e1e270ecd 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 new file mode 100644 index 000000000..b718f5074 --- /dev/null +++ b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php @@ -0,0 +1,229 @@ +privateKey, $this->publicKey] = AccessToken::generateKeyPair(); + $this->issuer = new AccessToken($this->privateKey, $this->publicKey, $this->iss); + $this->verifier = new Asymmetric($this->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']); + $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->assertSame($this->issuer->getKeyId(), $this->verifier->getKeyId()); + } + + public function testIssuerCheckPasses(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $claims = (new Asymmetric($this->publicKey, issuer: $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'); + (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 = (new Asymmetric($this->publicKey, audience: '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'); + (new Asymmetric($this->publicKey, audience: '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 = (new Asymmetric($this->publicKey, allowExpired: true))->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'); + } + + 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'); + (new Asymmetric($this->publicKey, allowExpired: true))->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'); + (new Asymmetric($this->publicKey, type: 'JWT'))->verify($token); + } + + public function testTypeMatchAccepted(): void + { + $token = $this->issuer->issue('u', ['aud'], 'c', 1000, 3600); + $claims = (new Asymmetric($this->publicKey, type: '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); + } +} diff --git a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php new file mode 100644 index 000000000..4a3d42ac9 --- /dev/null +++ b/packages/auth/tests/Auth/Verifiers/SymmetricTest.php @@ -0,0 +1,74 @@ +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 = (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 + { + $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'); + (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 = (new Symmetric($this->secret, leeway: 60))->verify($token); + + $this->assertSame('u', $claims['sub']); + } +} diff --git a/packages/auth/tests/StoreTest.php b/packages/auth/tests/StoreTest.php index 9b1c1b49e..5d282a5ba 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();