diff --git a/README.md b/README.md index ff7187938..f9e25f248 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The source of truth for the [utopia-php](https://github.com/utopia-php) libraries. Each `packages/` is an independent Composer library; development happens here, and every push is mirrored to its read-only repository (e.g. `utopia-php/http`), so Composer/Packagist distribution is unchanged — mechanics in [docs/distribution.md](docs/distribution.md). -[`utopia-php/cache`](https://github.com/appwrite/appwrite/tree/main/packages/cache), [`utopia-php/circuit-breaker`](https://github.com/appwrite/appwrite/tree/main/packages/circuit-breaker), [`utopia-php/client`](https://github.com/appwrite/appwrite/tree/main/packages/client), [`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/di`](https://github.com/appwrite/appwrite/tree/main/packages/di), [`utopia-php/image`](https://github.com/appwrite/appwrite/tree/main/packages/image), [`utopia-php/messaging`](https://github.com/appwrite/appwrite/tree/main/packages/messaging), [`utopia-php/openapi`](https://github.com/appwrite/appwrite/tree/main/packages/openapi), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/schedule`](https://github.com/appwrite/appwrite/tree/main/packages/schedule), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/span`](https://github.com/appwrite/appwrite/tree/main/packages/span), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system), [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry), [`utopia-php/user-agent`](https://github.com/appwrite/appwrite/tree/main/packages/user-agent), [`utopia-php/validators`](https://github.com/appwrite/appwrite/tree/main/packages/validators) and [`utopia-php/websocket`](https://github.com/appwrite/appwrite/tree/main/packages/websocket) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. +[`utopia-php/auth`](https://github.com/appwrite/appwrite/tree/main/packages/auth), [`utopia-php/cache`](https://github.com/appwrite/appwrite/tree/main/packages/cache), [`utopia-php/circuit-breaker`](https://github.com/appwrite/appwrite/tree/main/packages/circuit-breaker), [`utopia-php/client`](https://github.com/appwrite/appwrite/tree/main/packages/client), [`utopia-php/compression`](https://github.com/appwrite/appwrite/tree/main/packages/compression), [`utopia-php/di`](https://github.com/appwrite/appwrite/tree/main/packages/di), [`utopia-php/image`](https://github.com/appwrite/appwrite/tree/main/packages/image), [`utopia-php/messaging`](https://github.com/appwrite/appwrite/tree/main/packages/messaging), [`utopia-php/openapi`](https://github.com/appwrite/appwrite/tree/main/packages/openapi), [`utopia-php/pools`](https://github.com/appwrite/appwrite/tree/main/packages/pools), [`utopia-php/schedule`](https://github.com/appwrite/appwrite/tree/main/packages/schedule), [`utopia-php/smtp`](https://github.com/appwrite/appwrite/tree/main/packages/smtp), [`utopia-php/span`](https://github.com/appwrite/appwrite/tree/main/packages/span), [`utopia-php/system`](https://github.com/appwrite/appwrite/tree/main/packages/system), [`utopia-php/telemetry`](https://github.com/appwrite/appwrite/tree/main/packages/telemetry), [`utopia-php/user-agent`](https://github.com/appwrite/appwrite/tree/main/packages/user-agent), [`utopia-php/validators`](https://github.com/appwrite/appwrite/tree/main/packages/validators) and [`utopia-php/websocket`](https://github.com/appwrite/appwrite/tree/main/packages/websocket) are maintained in Appwrite. Their distribution mirrors and Packagist packages remain available. ## Quickstart @@ -60,7 +60,6 @@ graph TD platform --> servers queue --> servers audit - auth cdn config console diff --git a/packages/auth/.github/workflows/mirror.yml b/packages/auth/.github/workflows/mirror.yml deleted file mode 100644 index ba294f347..000000000 --- a/packages/auth/.github/workflows/mirror.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Mirror - -on: - pull_request_target: - types: [opened] - issues: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - redirect: - uses: utopia-php/monorepo/.github/workflows/mirror-redirect.yml@main - with: - package: auth diff --git a/packages/auth/.gitignore b/packages/auth/.gitignore deleted file mode 100644 index 386aa5862..000000000 --- a/packages/auth/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/vendor/ -/.vscode/ -.phpunit.result.cache -tests/chunk.php -.idea/ -.env -composer.lock diff --git a/packages/auth/AGENTS.md b/packages/auth/AGENTS.md deleted file mode 100644 index 96f239299..000000000 --- a/packages/auth/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# Utopia Auth Agent Notes - -Use the package documentation as the source of truth before changing public -behavior or examples: - -- [README.md](README.md) for the feature overview and documentation index. -- [docs/oauth2.md](docs/oauth2.md) for OAuth2 and OpenID Connect token examples, - resource indicators, prompts, and pushed authorization request URIs. -- [docs/jwt.md](docs/jwt.md) for generic JWS/JWT verification behavior and - claim/header enum references. -- [docs/hashing.md](docs/hashing.md), [docs/proofs.md](docs/proofs.md), and - [docs/store.md](docs/store.md) for the older authentication primitives. - -Keep examples and helper docs close to the protocol or primitive they describe. -When adding OAuth2 or OpenID Connect helpers, update `docs/oauth2.md` rather than -expanding `docs/jwt.md`. diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/packages/auth/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/packages/auth/Dockerfile b/packages/auth/Dockerfile deleted file mode 100644 index 3a5cb3d81..000000000 --- a/packages/auth/Dockerfile +++ /dev/null @@ -1,54 +0,0 @@ -FROM composer:2.8 AS composer - -ARG TESTING=false -ENV TESTING=$TESTING - -WORKDIR /usr/local/src/ -COPY composer.json /usr/local/src/ - -RUN composer update \ - --ignore-platform-reqs \ - --optimize-autoloader \ - --no-plugins \ - --no-scripts \ - --prefer-dist - -FROM php:8.4-cli-alpine AS compile - -RUN apk add --no-cache \ - git \ - autoconf \ - make \ - g++ \ - libsodium-dev - -# Build scrypt extension -FROM compile AS scrypt -RUN pecl install scrypt - -FROM compile AS final - -LABEL maintainer="team@appwrite.io" - -WORKDIR /usr/src/code - -# Enable hash extension (built-in) -# Install and enable sodium extension -RUN docker-php-ext-install sodium - -# Copy and enable scrypt extension -COPY --from=scrypt /usr/local/lib/php/extensions/no-debug-non-zts-20240924/scrypt.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/scrypt.so - -RUN docker-php-ext-enable scrypt - -# Configure PHP -RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \ - && echo "memory_limit=256M" >> $PHP_INI_DIR/php.ini - -# Copy composer dependencies -COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor - -# Add Source Code -COPY . /usr/src/code - -CMD [ "tail", "-f", "/dev/null" ] \ No newline at end of file diff --git a/packages/auth/LICENSE b/packages/auth/LICENSE deleted file mode 100644 index 6ecf64180..000000000 --- a/packages/auth/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Utopia - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/packages/auth/README.md b/packages/auth/README.md deleted file mode 100644 index 111c6b0a1..000000000 --- a/packages/auth/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Utopia Auth - -> [!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. - -![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 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 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 -``` - -```php -hash('user-password'); -$isValid = $password->verify('user-password', $hash); -``` - -## System requirements - -Utopia Auth requires PHP 8.3 or later. We recommend using the latest PHP version whenever possible. - -## Features - -- **Password hashing** — Argon2, Bcrypt, Scrypt (and a modified Scrypt), SHA, PHPass, and MD5 (legacy only) -- **Authentication proofs** — cryptographically random tokens, one-time codes (e.g. 2FA), and human-readable phrases -- **Data store** — a base64-encodable key/value envelope for serializing authentication state -- **Token issuers** — mint signed [JWS](https://datatracker.ietf.org/doc/html/rfc7515): application-defined HS256 JWTs, OAuth2 access tokens (RFC 9068), refresh tokens, and OpenID Connect ID tokens -- **Token verifiers** — verify RS256/HS256 JWS with an `alg`-confusion guard and standard claim checks -- **OAuth2 helpers** — RFC 8707 resource indicators, OpenID Connect prompts, RFC 9126 pushed authorization request URIs, and OAuth Client ID Metadata Documents - -## Documentation - -- [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) — application token issuance, JWS mechanics, verification, and claim/header names -- [OAuth2 and OpenID Connect](docs/oauth2.md) — token examples and protocol helpers - -## Tests - -To run all unit tests, use the following Docker command: - -```bash -docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml tests -``` - -## Security - -We take security seriously. If you discover any security-related issues, please email security@appwrite.io instead of using the issue tracker. - -## Contributing - -All code contributions - including those of people having commit access - must go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. - -We truly ❤️ pull requests! If you wish to help, you can learn more about how you can contribute to this project in the [contribution guide](https://github.com/utopia-php/monorepo/blob/main/CONTRIBUTING.md). - -## Copyright and license - -The MIT License (MIT) [http://www.opensource.org/licenses/mit-license.php](http://www.opensource.org/licenses/mit-license.php) diff --git a/packages/auth/composer.json b/packages/auth/composer.json deleted file mode 100644 index 740dc5ea3..000000000 --- a/packages/auth/composer.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "utopia-php/auth", - "description": "A simple PHP authentication library", - "type": "library", - "keywords": [ - "php", - "auth", - "authentication", - "security" - ], - "license": "MIT", - "minimum-stability": "stable", - "authors": [ - { - "name": "Utopia PHP", - "email": "team@appwrite.io" - } - ], - "autoload": { - "psr-4": { - "Utopia\\Auth\\": "src/Auth" - } - }, - "autoload-dev": { - "psr-4": { - "Utopia\\Tests\\Auth\\": "tests/Auth" - } - }, - "scripts": { - "test": "phpunit --configuration phpunit.xml" - }, - "require": { - "php": ">=8.3", - "ext-hash": "*", - "ext-openssl": "*", - "ext-scrypt": "*", - "ext-sodium": "*" - } -} diff --git a/packages/auth/docker-compose.yml b/packages/auth/docker-compose.yml deleted file mode 100644 index 0f0bbf4f6..000000000 --- a/packages/auth/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - tests: - container_name: tests - image: auth-dev - build: - context: . - volumes: - - ./src:/usr/src/code/src - - ./tests:/usr/src/code/tests - - ./phpunit.xml:/usr/src/code/phpunit.xml - environment: - - TESTING=true - networks: - - utopia - -networks: - utopia: \ No newline at end of file diff --git a/packages/auth/docs/hashing.md b/packages/auth/docs/hashing.md deleted file mode 100644 index 687316b48..000000000 --- a/packages/auth/docs/hashing.md +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100644 index 180b04a77..000000000 --- a/packages/auth/docs/jwt.md +++ /dev/null @@ -1,110 +0,0 @@ -# JSON Web Tokens - -The library mints and verifies signed [JWS](https://datatracker.ietf.org/doc/html/rfc7515) -tokens. 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). For OAuth2 and OpenID Connect token examples, see -[OAuth2 and OpenID Connect](oauth2.md). - -## Issuing application tokens - -Use `Issuers\Symmetric\Jwt` for HS256 application tokens such as login state -or session cookies, without an OAuth2 token profile. - -```php -use Utopia\Auth\Issuers\Symmetric\Jwt; - -// Generate once and persist server-side. -$secret = Jwt::generateSecret(); -$jwt = (new Jwt($secret, 'https://example.com'))->issue( - audience: 'preview', // A non-empty string or list of non-empty strings. - duration: 600, - claims: ['purpose' => 'state'], -); -``` - -The issuer controls `iss`, `aud`, `iat`, and `exp`; custom claims cannot -override them. `duration` must be positive. Tokens are signed, not encrypted. -After [verification](#verifying-tokens), applications must check custom claims -and enforce browser binding, authorization, and replay protection as needed. - -## 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. - -```php -verify($jwt); -} catch (VerificationException) { - // malformed, bad signature, wrong alg/type, expired, or a claim mismatch -} -``` - -HS256 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')) - ->verify($jwt); -``` - -`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/oauth2.md b/packages/auth/docs/oauth2.md deleted file mode 100644 index 761ff0cb6..000000000 --- a/packages/auth/docs/oauth2.md +++ /dev/null @@ -1,353 +0,0 @@ -# OAuth2 and OpenID Connect - -Utopia Auth includes small value objects and token issuers for OAuth2 and -OpenID Connect authorization servers. The token issuers mint signed JWTs, while -the OAuth2 helpers parse request parameters at the protocol boundary so -application code can work with typed values. - -## 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 - -```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'] -); -``` - -## OpenID Connect ID tokens - -```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) - -`ResourceIndicators` parses the `resource` request parameter and keeps resource -server identifiers as a normalized list of absolute HTTP(S) URIs without -fragments. - -```php -isSubsetOf($previouslyGrantedResources); -$unchanged = $resources->equals($previouslyGrantedResources); -$audience = $resources->audience('https://cloud.example.com/v1/project'); -$serialized = $resources->toArray(); -``` - -For compatibility with clients that send a single resource identifier using -`audience`, pass it as the optional second argument. When `resource` is absent, -the audience becomes the resource indicator. When both are present, the -audience must exactly match one of the supplied resources. - -```php -$resources = ResourceIndicators::from(null, 'https://api.example.com/'); -``` - -`InvalidResourceException::ERROR_CODE` is `invalid_target`, matching RFC 8707. - -## OAuth2 rich authorization requests (RFC 9396) - -`AuthorizationDetails` reads a token's `authorization_details`: a JSON array of -typed entries, each an object with a `type` and, per its type, further fields. -`grants()` answers whether an entry of a given type lists a value in one of its -array-valued fields — an RFC 9396 §2 common field (`locations`, `actions`, -`datatypes`, `privileges`) or a field a type defines itself. `AuthorizationDetail` -enumerates those common field names. - -The constructor accepts the raw value and keeps only list-shaped input: a value -that is not a JSON array, or a field that is a JSON object rather than an array, -contributes nothing, so a malformed claim cannot grant access. Value comparison -is type-strict. - -RFC 9396 defines no wildcard. A profile that lets one identifier stand for every -value passes that sentinel as `$wildcard` to opt a field into it; left out, only -an exact value matches. - -```php - 'project', 'identifiers' => ['p1', '*'], 'actions' => ['read']], - ['type' => 'organization', 'identifiers' => ['t1']], -]); - -$details->grants('project', 'p1', 'identifiers'); // true (exact) -$details->grants('project', 'anything', 'identifiers', '*'); // true (wildcard opted in) -$details->grants('project', 'anything', 'identifiers'); // false (no wildcard) -$details->grants('project', 'read', AuthorizationDetail::Actions->value); // true (RFC common field) -$details->grants('organization', 't1', 'identifiers'); // true -``` - -`restrict()` narrows a field of every entry to the values a resolver allows, so -an issued token asserts only what the subject can reach now. The resolver gets -an entry's `type` and the field's values and returns the allowed subset; `null` -leaves an entry of a type it does not govern untouched, an empty result drops -the entry. The result can only narrow the grant: a value the entry did not list -is discarded, unless the entry lists `$wildcard`, in which case the result from -the resolver is taken as its expansion, as with `grants()`. `toArray()` yields -the entries for the `authorization_details` claim. - -```php -restrict('identifiers', fn (string $type, array $values): ?array => match ($type) { - 'organization' => $memberships->intersect($values), - default => null, -}); - -$issued->toArray(); -``` - -## OAuth2 redirect URI matching (RFC 8252) - -`RedirectUris` wraps a client's registered redirect URIs and matches a -presented `redirect_uri` against them. Matching is exact string comparison. -With `allowLoopback` enabled, http loopback URIs (`localhost`, -`127.0.0.1`, `[::1]`) match with any port per RFC 8252 Section 7.3 — native -and CLI clients bind an ephemeral port per run and cannot register it ahead -of time. RFC 8252 scopes that carve-out to native apps, which are public -clients (Section 8.4), so enable it for public clients only and keep -confidential clients on exact matching. The loopback hosts are an exact -allowlist (lookalikes such as `localhost.evil.com` never qualify), the host -itself must still match, and scheme, path, and query compare exactly. - -```php -matches('https://example.com/callback'); // true (exact) -$uris->matches('http://localhost:54155/callback', $isPublicClient); // true (loopback, port ignored) -$uris->matches('http://localhost:54155/callback'); // false (strict without opt-in) -$uris->matches('http://127.0.0.1:54155/callback', $isPublicClient); // false (host must match) -$uris->matches('https://example.com/other'); // false -``` - -## OpenID Connect prompts - -`Prompts` parses the OIDC `prompt` authorization request parameter into typed -`Prompt` enum values. Empty input means no prompt preference was requested, -duplicate values are collapsed, and `prompt=none` cannot be combined with any -other prompt value. - -```php -contains(Prompt::Login); -$serialized = $prompts->toArray(); // ['login', 'consent', 'select_account'] -$parameter = $prompts->toString(); // 'login consent select_account' -``` - -Invalid prompt values throw `InvalidPromptException`. -`InvalidPromptException::ERROR_CODE` is `invalid_request`. - -## Pushed authorization requests (RFC 9126) - -`PAR` represents a pushed authorization request `request_uri`. Build one from a -stored request id when returning a PAR response, or parse one from a -`request_uri` parameter when the client later calls the authorization endpoint. - -```php -requestUri(); // 'urn:appwrite:oauth2:request:grant123' -$id = $par->id(); // 'grant123' - -$parsed = PAR::fromRequestUri('urn:appwrite:oauth2:request:', $requestUri); -$storedRequestId = $parsed->id(); -``` - -Malformed request URIs throw `InvalidRequestUriException`. -`InvalidRequestUriException::ERROR_CODE` is `invalid_request`. - - - -## OAuth Client ID Metadata Documents - - - -OAuth Client ID Metadata Documents let a client use an HTTPS URL as its -`client_id`. An authorization server fetches the JSON metadata published at -that URL, allowing clients to identify themselves without prior registration. - -Utopia Auth validates the protocol values after the authorization server has -retrieved the document. HTTP requests, response-size limits, redirect handling, -caching, and SSRF protection remain the responsibility of the authorization -server. - -```php -tokenEndpointAuthMethod(); -$grantTypes = $document->grantTypes(); -$redirectUris = $document->redirectUris(); -$metadata = $document->toArray(); -``` - -The identifier keeps its original serialization because Client Identifier URLs -use exact string comparison. `ClientIdMetadataDocument` verifies the echoed -`client_id`, rejects shared secrets and private key material, and validates the -standard metadata fields it exposes. Authorization-server-specific support for -authentication methods, grants, and response types should be checked after -parsing. - -For isolated development environments, `ClientIdentifierUrl::fromString()` -accepts an `allowHttp` argument. Production authorization servers should leave -it disabled and must independently prevent requests to special-use addresses. - -Invalid identifiers and documents throw `InvalidClientMetadataException`. - -## Verifying OAuth2 and OIDC tokens - -OAuth2 and OIDC tokens are verified with the JWT verifiers. Pin the token type -where possible so one token kind cannot 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, such as 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); -``` diff --git a/packages/auth/docs/proofs.md b/packages/auth/docs/proofs.md deleted file mode 100644 index 3958eb888..000000000 --- a/packages/auth/docs/proofs.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index e85acd8b0..000000000 --- a/packages/auth/docs/store.md +++ /dev/null @@ -1,33 +0,0 @@ -# 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/phpunit.xml b/packages/auth/phpunit.xml deleted file mode 100644 index 356a2524c..000000000 --- a/packages/auth/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - tests - - - - - src - - - diff --git a/packages/auth/src/Auth/Enums/AuthorizationDetail.php b/packages/auth/src/Auth/Enums/AuthorizationDetail.php deleted file mode 100644 index 33ab4595f..000000000 --- a/packages/auth/src/Auth/Enums/AuthorizationDetail.php +++ /dev/null @@ -1,32 +0,0 @@ - Hash-specific options - */ - protected array $options = []; - - /** - * Set hashing options - * - * @param string $key The option key to set - * @param mixed $value The value to set for the option - */ - public function setOption(string $key, mixed $value): static - { - $this->options[$key] = $value; - - return $this; - } - - /** - * Set multiple hashing options at once - * - * @param array $options Array of options to set - */ - public function setOptions(array $options): static - { - foreach ($options as $key => $value) { - $this->setOption($key, $value); - } - - return $this; - } - - /** - * Get a specific option value - * - * @param string $key The option key to retrieve - * @param mixed $default Default value if option doesn't exist - * @return mixed The option value or default if not found - */ - public function getOption(string $key, mixed $default = null): mixed - { - return $this->options[$key] ?? $default; - } - - /** - * Get hashing options - * - * @return array Hash-specific options - */ - public function getOptions(): array - { - return $this->options; - } - - /** - * Hash a value - */ - abstract public function hash(string $value): string; - - /** - * Verify a value against a hash - */ - abstract public function verify(string $value, string $hash): bool; - - /** - * Get the name of the hash algorithm - */ - abstract public function getName(): string; -} diff --git a/packages/auth/src/Auth/Hashes/Argon2.php b/packages/auth/src/Auth/Hashes/Argon2.php deleted file mode 100644 index 5bc8a93cc..000000000 --- a/packages/auth/src/Auth/Hashes/Argon2.php +++ /dev/null @@ -1,87 +0,0 @@ -setOption('type', $this->getName()); - $this->setOption('memory_cost', 65536); - $this->setOption('time_cost', 4); - $this->setOption('threads', 3); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - return password_hash($value, PASSWORD_ARGON2ID, $this->getOptions()); - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return password_verify($value, $hash); - } - - /** - * Set memory cost - * - * @param int $cost Memory cost in KiB - * - * @throws \InvalidArgumentException - */ - public function setMemoryCost(int $cost): static - { - $this->setOption('memory_cost', $cost); - - return $this; - } - - /** - * Set time cost - * - * @param int $cost Number of iterations - * - * @throws \InvalidArgumentException - */ - public function setTimeCost(int $cost): static - { - $this->setOption('time_cost', $cost); - - return $this; - } - - /** - * Set number of threads - * - * @param int $threads Number of threads to use - * - * @throws \InvalidArgumentException - */ - public function setThreads(int $threads): static - { - $this->setOption('threads', $threads); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'argon2'; - } -} diff --git a/packages/auth/src/Auth/Hashes/Bcrypt.php b/packages/auth/src/Auth/Hashes/Bcrypt.php deleted file mode 100644 index fc4869f26..000000000 --- a/packages/auth/src/Auth/Hashes/Bcrypt.php +++ /dev/null @@ -1,61 +0,0 @@ -setOption('type', $this->getName()); - $this->setOption('cost', 8); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - return password_hash($value, PASSWORD_BCRYPT, $this->getOptions()); - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return password_verify($value, $hash); - } - - /** - * Set cost parameter - * - * @param int $cost Cost parameter between 4 and 31 - * - * @throws \InvalidArgumentException - */ - public function setCost(int $cost): static - { - if ($cost < 4 || $cost > 31) { - throw new \InvalidArgumentException('Cost must be between 4 and 31'); - } - - $this->setOption('cost', $cost); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'bcrypt'; - } -} diff --git a/packages/auth/src/Auth/Hashes/MD5.php b/packages/auth/src/Auth/Hashes/MD5.php deleted file mode 100644 index eefaaf028..000000000 --- a/packages/auth/src/Auth/Hashes/MD5.php +++ /dev/null @@ -1,42 +0,0 @@ -setOption('type', $this->getName()); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - return md5($value); - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return hash_equals($hash, $this->hash($value)); - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'md5'; - } -} diff --git a/packages/auth/src/Auth/Hashes/PHPass.php b/packages/auth/src/Auth/Hashes/PHPass.php deleted file mode 100644 index a688317dd..000000000 --- a/packages/auth/src/Auth/Hashes/PHPass.php +++ /dev/null @@ -1,263 +0,0 @@ -setOption('type', $this->getName()); - $this->setOption('iteration_count_log2', 8); - $this->setOption('portable_hashes', false); - $this->setOption('random_state', $randomState); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - $options = $this->getOptions(); - $random = ''; - - if (! $options['portable_hashes']) { - $random = $this->getRandomBytes(16); - $hash = crypt($value, $this->gensaltBlowfish($random)); - if (\strlen($hash) === 60) { - return $hash; - } - } - - if (\strlen($random) < 6) { - $random = $this->getRandomBytes(6); - } - - $hash = $this->cryptPrivate($value, $this->gensaltPrivate($random)); - if (\strlen($hash) === 34) { - return $hash; - } - - return '*'; - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - $verificationHash = $this->cryptPrivate($value, $hash); - if ($verificationHash[0] === '*') { - $verificationHash = crypt($value, $hash); - } - - return hash_equals($hash, $verificationHash); - } - - /** - * Get random bytes - */ - protected function getRandomBytes(int $count): string - { - if ($count < 1) { - throw new \Exception('Argument count must be a positive integer'); - } - - $output = ''; - if (@is_readable('/dev/urandom') && ($fh = @fopen('/dev/urandom', 'rb'))) { - $readOutput = fread($fh, $count); - if ($readOutput !== false) { - $output = $readOutput; - } - fclose($fh); - } - - if (\strlen($output) < $count) { - $output = ''; - $options = $this->getOptions(); - - for ($i = 0; $i < $count; $i += 16) { - $options['random_state'] = md5(microtime() . $options['random_state']); - $output .= md5($options['random_state'], true); - } - - $output = substr($output, 0, $count); - } - - return $output; - } - - /** - * Encode in base64 - */ - protected function encode64(string $input, int $count): string - { - if ($count < 1) { - throw new \Exception('Argument count must be a positive integer'); - } - - $output = ''; - $i = 0; - do { - $value = \ord($input[$i++]); - $output .= $this->itoa64[$value & 0x3F]; - if ($i < $count) { - $value |= \ord($input[$i]) << 8; - } - $output .= $this->itoa64[($value >> 6) & 0x3F]; - if ($i++ >= $count) { - break; - } - if ($i < $count) { - $value |= \ord($input[$i]) << 16; - } - $output .= $this->itoa64[($value >> 12) & 0x3F]; - if ($i++ >= $count) { - break; - } - $output .= $this->itoa64[($value >> 18) & 0x3F]; - } while ($i < $count); - - return $output; - } - - /** - * Generate salt for private key - */ - 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)]; - - return $output . $this->encode64($input, 6); - } - - /** - * Generate salt for Blowfish - */ - private function gensaltBlowfish(string $input): string - { - $options = $this->getOptions(); - $itoa64 = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - $output = '$2a$'; - $output .= \chr(\ord('0') + \intval($options['iteration_count_log2'] / 10)); - $output .= \chr(\ord('0') + $options['iteration_count_log2'] % 10); - $output .= '$'; - - $i = 0; - do { - $c1 = \ord($input[$i++]); - $output .= $itoa64[$c1 >> 2]; - $c1 = ($c1 & 0x03) << 4; - if ($i >= 16) { - $output .= $itoa64[$c1]; - break; - } - - $c2 = \ord($input[$i++]); - $c1 |= $c2 >> 4; - $output .= $itoa64[$c1]; - $c1 = ($c2 & 0x0F) << 2; - - $c2 = \ord($input[$i++]); - $c1 |= $c2 >> 6; - $output .= $itoa64[$c1]; - $output .= $itoa64[$c2 & 0x3F]; - } while (1); - - return $output; - } - - /** - * Crypt private - */ - private function cryptPrivate(string $password, string $setting): string - { - $output = '*0'; - if (substr($setting, 0, 2) === $output) { - $output = '*1'; - } - - $id = substr($setting, 0, 3); - // We use "$P$", phpBB3 uses "$H$" for the same thing - if ($id !== '$P$' && $id !== '$H$') { - return $output; - } - - $count_log2 = strpos($this->itoa64, $setting[3]); - if ($count_log2 < 7 || $count_log2 > 30) { - return $output; - } - - $count = 1 << $count_log2; - $salt = substr($setting, 4, 8); - if (\strlen($salt) !== 8) { - return $output; - } - - $hash = md5($salt . $password, true); - do { - $hash = md5($hash . $password, true); - } while (--$count); - - $output = substr($setting, 0, 12); - - return $output . $this->encode64($hash, 16); - } - - /** - * Set iteration count (log2) - * - * @param int $count Iteration count (log2) between 4 and 31 - * - * @throws \InvalidArgumentException - */ - public function setIterationCount(int $count): PHPass - { - if ($count < 4 || $count > 31) { - throw new \InvalidArgumentException('Iteration count must be between 4 and 31'); - } - - $this->setOption('iteration_count_log2', $count); - - return $this; - } - - /** - * Set portable hashes mode - * - * @param bool $portable Whether to use portable hashes - */ - public function setPortableHashes(bool $portable): PHPass - { - $this->setOption('portable_hashes', $portable); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'phpass'; - } -} diff --git a/packages/auth/src/Auth/Hashes/Plaintext.php b/packages/auth/src/Auth/Hashes/Plaintext.php deleted file mode 100644 index be8652331..000000000 --- a/packages/auth/src/Auth/Hashes/Plaintext.php +++ /dev/null @@ -1,42 +0,0 @@ -setOption('type', $this->getName()); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - return $value; - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return hash_equals($hash, $this->hash($value)); - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'plaintext'; - } -} diff --git a/packages/auth/src/Auth/Hashes/Scrypt.php b/packages/auth/src/Auth/Hashes/Scrypt.php deleted file mode 100644 index f91f80f18..000000000 --- a/packages/auth/src/Auth/Hashes/Scrypt.php +++ /dev/null @@ -1,166 +0,0 @@ -setOption('type', $this->getName()); - $this->setOption('costCpu', 8); - $this->setOption('costMemory', 14); - $this->setOption('costParallel', 1); - $this->setOption('length', 64); - $this->setOption('salt', bin2hex(random_bytes(16))); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - if (! \function_exists('scrypt')) { - throw new \RuntimeException('The scrypt extension is required. Please install php-scrypt.'); - } - - $salt = $this->getOption('salt'); - $costCpu = $this->getOption('costCpu'); - $costMemory = $this->getOption('costMemory'); - $costParallel = $this->getOption('costParallel'); - $length = $this->getOption('length'); - - if (! \is_string($salt)) { - throw new \InvalidArgumentException('Salt must be a string'); - } - - if (! \is_int($costCpu) || ! \is_int($costMemory) || ! \is_int($costParallel) || ! \is_int($length)) { - throw new \InvalidArgumentException('Scrypt cost and length options must be integers'); - } - - $hash = scrypt( - $value, - $salt, - $costCpu, - $costMemory, - $costParallel, - $length, - ); - - if ($hash === false) { - throw new \RuntimeException('Failed to hash using scrypt'); - } - - return $hash; - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return hash_equals($hash, $this->hash($value)); - } - - /** - * Set CPU cost parameter - * - * @param int $cost CPU cost parameter N. Must be larger than 1 and a power of 2 - * - * @throws \InvalidArgumentException - */ - public function setCpuCost(int $cost): self - { - if ($cost <= 1 || ($cost & ($cost - 1)) !== 0) { - throw new \InvalidArgumentException('CPU cost must be > 1 and a power of 2'); - } - - $this->setOption('costCpu', $cost); - - return $this; - } - - /** - * Set memory cost parameter - * - * @param int $cost Memory cost parameter r - * - * @throws \InvalidArgumentException - */ - public function setMemoryCost(int $cost): static - { - if ($cost < 1) { - throw new \InvalidArgumentException('Memory cost must be >= 1'); - } - - $this->setOption('costMemory', $cost); - - return $this; - } - - /** - * Set parallelization parameter - * - * @param int $cost Parallelization parameter p - * - * @throws \InvalidArgumentException - */ - public function setParallelCost(int $cost): static - { - if ($cost < 1) { - throw new \InvalidArgumentException('Parallel cost must be >= 1'); - } - - $this->setOption('costParallel', $cost); - - return $this; - } - - /** - * Set output length - * - * @param int $length Desired output length in bytes - * - * @throws \InvalidArgumentException - */ - public function setLength(int $length): static - { - if ($length < 16) { - throw new \InvalidArgumentException('Length must be >= 16 bytes'); - } - - $this->setOption('length', $length); - - return $this; - } - - /** - * Set salt value - * - * @param string $salt Salt value for the hash - * - * @throws \InvalidArgumentException - */ - public function setSalt(string $salt): static - { - if ($salt === '' || $salt === '0') { - throw new \InvalidArgumentException('Salt cannot be empty'); - } - - $this->setOption('salt', $salt); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'scrypt'; - } -} diff --git a/packages/auth/src/Auth/Hashes/ScryptModified.php b/packages/auth/src/Auth/Hashes/ScryptModified.php deleted file mode 100644 index c55047832..000000000 --- a/packages/auth/src/Auth/Hashes/ScryptModified.php +++ /dev/null @@ -1,173 +0,0 @@ -setOption('type', $this->getName()); - - // Set default options with secure random values - $this->setOption('salt', base64_encode($salt)); - $this->setOption('saltSeparator', base64_encode($saltSeparator)); - $this->setOption('signerKey', base64_encode($signerKey)); - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - $options = $this->getOptions(); - - if (! \is_string($options['signerKey'])) { - throw new \InvalidArgumentException('Signer key must be a string'); - } - - $derivedKeyBytes = $this->generateDerivedKey($value); - $signerKeyBytes = base64_decode($options['signerKey']); - - return base64_encode($this->hashKeys($signerKeyBytes, $derivedKeyBytes)); - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return hash_equals($hash, $this->hash($value)); - } - - /** - * Generate derived key using scrypt - * - * @throws \RuntimeException If scrypt extension is not installed - */ - private function generateDerivedKey(string $value): string - { - if (! \function_exists('scrypt')) { - throw new \RuntimeException('The scrypt extension is required. Please install php-scrypt.'); - } - - $options = $this->getOptions(); - - if (! \is_string($options['salt']) || ! \is_string($options['saltSeparator'])) { - throw new \InvalidArgumentException('Salt and salt separator must be strings'); - } - - $saltBytes = base64_decode($options['salt']); - $saltSeparatorBytes = base64_decode($options['saltSeparator']); - - $value = mb_convert_encoding($value, 'UTF-8'); - $derivedKey = scrypt($value, $saltBytes . $saltSeparatorBytes, 16384, 8, 1, 64); - if ($derivedKey === false) { - throw new \RuntimeException('Failed to generate derived key using scrypt'); - } - - $result = hex2bin($derivedKey); - if ($result === false) { - throw new \RuntimeException('Failed to convert derived key from hex to binary'); - } - - return $result; - } - - /** - * Hash keys using AES-256-CTR - * - * @throws \RuntimeException If encryption fails - */ - private function hashKeys(string $signerKeyBytes, string $derivedKeyBytes): string - { - $key = substr($derivedKeyBytes, 0, 32); - $iv = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; - - $result = openssl_encrypt($signerKeyBytes, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv); - if ($result === false) { - throw new \RuntimeException('Failed to encrypt using AES-256-CTR'); - } - - return $result; - } - - /** - * Set salt value - * - * @param string $salt Base64 encoded salt value - * - * @throws \InvalidArgumentException - */ - public function setSalt(string $salt): static - { - if ($salt === '' || $salt === '0') { - throw new \InvalidArgumentException('Salt cannot be empty'); - } - - if (! preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $salt)) { - throw new \InvalidArgumentException('Salt must be base64 encoded'); - } - - $this->setOption('salt', $salt); - - return $this; - } - - /** - * Set salt separator - * - * @param string $separator Base64 encoded salt separator - * - * @throws \InvalidArgumentException - */ - public function setSaltSeparator(string $separator): static - { - if (! preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $separator)) { - throw new \InvalidArgumentException('Salt separator must be base64 encoded'); - } - - $this->setOption('saltSeparator', $separator); - - return $this; - } - - /** - * Set signer key - * - * @param string $key Base64 encoded signer key - * - * @throws \InvalidArgumentException - */ - public function setSignerKey(string $key): static - { - if ($key === '' || $key === '0') { - throw new \InvalidArgumentException('Signer key cannot be empty'); - } - - if (! preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $key)) { - throw new \InvalidArgumentException('Signer key must be base64 encoded'); - } - - $this->setOption('signerKey', $key); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'scryptMod'; - } -} diff --git a/packages/auth/src/Auth/Hashes/Sha.php b/packages/auth/src/Auth/Hashes/Sha.php deleted file mode 100644 index a56b7946c..000000000 --- a/packages/auth/src/Auth/Hashes/Sha.php +++ /dev/null @@ -1,98 +0,0 @@ -setOption('version', 'sha256'); - } - - /** - * Valid SHA versions - */ - private const array VALID_VERSIONS = [ - 'sha1', - 'sha224', - 'sha256', - 'sha384', - 'sha512', - 'sha3-224', - 'sha3-256', - 'sha3-384', - 'sha3-512', - ]; - - /** - * Set SHA version - * - * @param string $version SHA version to use - * - * @throws \InvalidArgumentException - */ - public function setVersion(string $version): static - { - if (! \in_array($version, self::VALID_VERSIONS, true)) { - throw new \InvalidArgumentException('Invalid SHA version. Valid versions are: ' . implode(', ', self::VALID_VERSIONS)); - } - - $this->setOption('version', $version); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function hash(string $value): string - { - $version = $this->getOption('version'); - if (! \is_string($version)) { - throw new \RuntimeException('SHA version must be a string'); - } - - return hash($version, $value); - } - - /** - * {@inheritdoc} - */ - public function verify(string $value, string $hash): bool - { - return hash_equals($hash, $this->hash($value)); - } - - /** - * {@inheritdoc} - */ - public function getName(): string - { - return 'sha'; - } -} diff --git a/packages/auth/src/Auth/Issuer.php b/packages/auth/src/Auth/Issuer.php deleted file mode 100644 index 838b2ef15..000000000 --- a/packages/auth/src/Auth/Issuer.php +++ /dev/null @@ -1,105 +0,0 @@ -". - * - * @throws \Exception When the issuer is missing. - */ - public function __construct(protected readonly string $issuer) - { - if ($issuer === '' || $issuer === '0') { - throw new \Exception('An issuer is required'); - } - } - - /** - * The JWS "typ" header value for tokens produced by this issuer - * (e.g. "JWT" for an OIDC id_token, "at+jwt" for an RFC 9068 access token). - */ - abstract protected function getType(): string; - - /** - * The JWS "alg" header value (e.g. "RS256", "HS256"). - */ - abstract protected function getAlgorithm(): string; - - /** - * Produce the raw (binary) signature for the given signing input. - */ - abstract protected function signInput(string $signingInput): string; - - /** - * Extra header fields to merge in on top of "typ" and "alg" - * (e.g. a "kid"). Empty by default. - * - * @return array - */ - protected function getHeaders(): array - { - return []; - } - - /** - * Encode a set of claims into a signed compact JWS. The header is built - * from {@see getType()}, {@see getAlgorithm()} and {@see getHeaders()}; - * the signature is delegated to {@see signInput()}. - * - * @param array $claims - * - * @throws \JsonException When the header or claims cannot be JSON-encoded. - * @throws \Exception When signing fails. - */ - protected function sign(array $claims): string - { - $header = [ - Header::Type->value => $this->getType(), - Header::Algorithm->value => $this->getAlgorithm(), - ...$this->getHeaders(), - ]; - - $signingInput = $this->base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)) - . '.' - . $this->base64UrlEncode(json_encode($claims, JSON_THROW_ON_ERROR)); - - return $signingInput . '.' . $this->base64UrlEncode($this->signInput($signingInput)); - } - - /** - * Generate a unique token identifier suitable for the "jti" claim - * (RFC 7519 §4.1.7) as a random hex string. - * - * @param int<1, max> $bytes - * - * @throws \Exception When sufficient randomness is unavailable. - */ - protected function generateJti(int $bytes = 16): string - { - return bin2hex(random_bytes($bytes)); - } - - /** - * Base64url-encode without padding (RFC 7515 §2). - */ - protected function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } -} diff --git a/packages/auth/src/Auth/Issuers/Asymmetric.php b/packages/auth/src/Auth/Issuers/Asymmetric.php deleted file mode 100644 index 625c53cb1..000000000 --- a/packages/auth/src/Auth/Issuers/Asymmetric.php +++ /dev/null @@ -1,202 +0,0 @@ - $bits, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - if ($resource === false) { - throw new \Exception('Unable to generate an RSA key pair'); - } - - return [ - self::exportPrivateKey($resource), - self::exportPublicKey($resource), - ]; - } - - /** - * Export the PEM-encoded private key from an OpenSSL key resource. - * - * @throws \Exception When the key cannot be exported. - */ - private static function exportPrivateKey(\OpenSSLAsymmetricKey $resource): string - { - $privateKey = ''; - if (!openssl_pkey_export($resource, $privateKey)) { - throw new \Exception('Unable to export the private key'); - } - - return $privateKey; - } - - /** - * Export the PEM-encoded public key from an OpenSSL key resource. - * - * @throws \Exception When the public key cannot be derived. - */ - private static function exportPublicKey(\OpenSSLAsymmetricKey $resource): string - { - $details = openssl_pkey_get_details($resource); - if ($details === false || !isset($details['key'])) { - throw new \Exception('Unable to export the public key'); - } - - return $details['key']; - } - - /** - * Get the JWS "kid" header. When none was supplied it is derived - * deterministically from the public key's RSA modulus, so the same key - * always yields the same id. - * - * @throws \Exception When the public key cannot be parsed. - */ - public function getKeyId(): string - { - return $this->keyId ??= $this->deriveKeyId($this->getModulus()); - } - - /** - * Build the public key as a JWK (RFC 7517) suitable for publishing on a - * JWKS endpoint so clients can verify the issued tokens. - * - * @return array - * - * @throws \Exception When the public key cannot be parsed. - */ - public function getPublicJwk(): array - { - $publicKey = openssl_pkey_get_public($this->publicKey); - if ($publicKey === false) { - throw new \Exception('Unable to parse the public key'); - } - - $details = openssl_pkey_get_details($publicKey); - if ($details === false || !isset($details['rsa'])) { - throw new \Exception('Public key is not an RSA key'); - } - - return [ - 'kty' => 'RSA', - 'use' => 'sig', - 'alg' => 'RS256', - // Reuse the modulus already in $details rather than re-parsing - // the key via getKeyId() -> getModulus(). - 'kid' => $this->keyId ??= $this->deriveKeyId($details['rsa']['n']), - 'n' => $this->base64UrlEncode($details['rsa']['n']), - 'e' => $this->base64UrlEncode($details['rsa']['e']), - ]; - } - - protected function getAlgorithm(): string - { - return 'RS256'; - } - - /** - * @return array - * - * @throws \Exception When the public key cannot be parsed. - */ - #[\Override] - protected function getHeaders(): array - { - return [Header::KeyId->value => $this->getKeyId()]; - } - - /** - * @throws \Exception When the private key cannot be parsed or signing fails. - */ - protected function signInput(string $signingInput): string - { - $privateKey = openssl_pkey_get_private($this->privateKey); - if ($privateKey === false) { - throw new \Exception('Unable to parse the private key'); - } - - $signature = ''; - if (!openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256)) { - throw new \Exception('Unable to sign the token'); - } - - return $signature; - } - - /** - * Derive a deterministic key id from the RSA modulus, so the same key - * always yields the same "kid". - */ - private function deriveKeyId(string $modulus): string - { - return hash('sha256', $modulus); - } - - /** - * Read the raw RSA modulus (the "n" parameter) from the public key. - * - * @throws \Exception When the public key cannot be parsed. - */ - protected function getModulus(): string - { - $publicKey = openssl_pkey_get_public($this->publicKey); - if ($publicKey === false) { - throw new \Exception('Unable to parse the public key'); - } - - $details = openssl_pkey_get_details($publicKey); - if ($details === false || !isset($details['rsa']['n'])) { - throw new \Exception('Public key is not an RSA key'); - } - - return $details['rsa']['n']; - } -} diff --git a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php b/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php deleted file mode 100644 index 45c658fe1..000000000 --- a/packages/auth/src/Auth/Issuers/Asymmetric/AccessToken.php +++ /dev/null @@ -1,98 +0,0 @@ - $audience The "aud" claim (the resource server identifiers). - * @param string $clientId The "client_id" claim (the client the token was issued to). - * @param int $authTime Time the end-user authenticated ("auth_time"), as a Unix timestamp. - * @param int $duration Lifetime of the token in seconds (used for "exp"). - * @param array $scopes Granted scopes; joined into the space-delimited "scope" claim when non-empty. - * @param string|null $jti The "jti" claim; a random identifier is generated when null. - * @param array $claims Additional claims to merge into the payload. - * - * @throws \Exception When signing fails. - */ - public function issue( - string $subject, - array $audience, - string $clientId, - int $authTime, - int $duration, - array $scopes = [], - ?string $jti = null, - array $claims = [], - ): string { - if ($audience === []) { - throw new \InvalidArgumentException('audience must contain at least one resource server identifier.'); - } - - if ($audience !== array_values($audience)) { - throw new \InvalidArgumentException('audience must be a list of resource server identifiers.'); - } - - foreach ($audience as $identifier) { - if ($identifier === '') { - throw new \InvalidArgumentException('audience must contain non-empty resource server identifiers.'); - } - } - - $now = time(); - - // "scope" is issuer-controlled; drop any caller-supplied value so it - // cannot be injected through $claims when $scopes is empty. - unset($claims[Claim::Scope->value]); - - $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 ($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 deleted file mode 100644 index 2d958aa11..000000000 --- a/packages/auth/src/Auth/Issuers/Asymmetric/IdToken.php +++ /dev/null @@ -1,100 +0,0 @@ - $claims Additional claims to merge into the payload. - * - * @throws \Exception When signing fails. - */ - public function issue( - string $subject, - string $audience, - int $authTime, - int $duration, - ?string $nonce = null, - ?string $accessToken = null, - ?string $code = null, - array $claims = [], - ): string { - $now = time(); - - // nonce/at_hash/c_hash are issuer-controlled; drop any caller-supplied - // 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[Claim::Nonce->value], $claims[Claim::AccessTokenHash->value], $claims[Claim::CodeHash->value]); - - $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 (!\in_array($nonce, [null, '', '0'], true)) { - $claims[Claim::Nonce->value] = $nonce; - } - - if (!\in_array($accessToken, [null, '', '0'], true)) { - $claims[Claim::AccessTokenHash->value] = $this->leftHalfHash($accessToken); - } - - if (!\in_array($code, [null, '', '0'], true)) { - $claims[Claim::CodeHash->value] = $this->leftHalfHash($code); - } - - return $this->sign($claims); - } - - /** - * OIDC §3.1.3.6 / §3.3.2.11: hash with the same algorithm family as the - * id_token signature (SHA-256 for RS256), take the left-most half - * (16 bytes / 128 bits), base64url-encode without padding. - */ - protected function leftHalfHash(string $value): string - { - return $this->base64UrlEncode(substr(hash('sha256', $value, true), 0, 16)); - } -} diff --git a/packages/auth/src/Auth/Issuers/Symmetric.php b/packages/auth/src/Auth/Issuers/Symmetric.php deleted file mode 100644 index 4e1153d85..000000000 --- a/packages/auth/src/Auth/Issuers/Symmetric.php +++ /dev/null @@ -1,76 +0,0 @@ - $bytes - * - * @throws \Exception When sufficient randomness is unavailable. - */ - public static function generateSecret(int $bytes = 32): string - { - return bin2hex(random_bytes($bytes)); - } - - /** - * Get the configured JWS "kid", or null when none was supplied. - */ - public function getKeyId(): ?string - { - return $this->keyId; - } - - protected function getAlgorithm(): string - { - return 'HS256'; - } - - /** - * @return array - */ - #[\Override] - protected function getHeaders(): array - { - return $this->keyId !== null ? [Header::KeyId->value => $this->keyId] : []; - } - - protected function signInput(string $signingInput): string - { - return hash_hmac('sha256', $signingInput, $this->secret, true); - } -} diff --git a/packages/auth/src/Auth/Issuers/Symmetric/Jwt.php b/packages/auth/src/Auth/Issuers/Symmetric/Jwt.php deleted file mode 100644 index 13a44f848..000000000 --- a/packages/auth/src/Auth/Issuers/Symmetric/Jwt.php +++ /dev/null @@ -1,60 +0,0 @@ - $audience Validated as a non-empty recipient or non-empty list of non-empty strings ("aud"). - * @param int $duration Positive lifetime in seconds (used for "exp"). - * @param array $claims Application claims; cannot override "iss", "aud", "iat" or "exp". - * - * @throws \InvalidArgumentException When the audience is invalid or the duration is not positive. - * @throws \JsonException When claims cannot be JSON-encoded. - * @throws \Exception When signing fails. - */ - public function issue(string|array $audience, int $duration, array $claims = []): string - { - if ($duration < 1) { - throw new \InvalidArgumentException('Token duration must be greater than zero'); - } - - $recipients = \is_array($audience) ? $audience : [$audience]; - if ($recipients === [] || !array_is_list($recipients)) { - throw new \InvalidArgumentException('Token audience must be a non-empty list of recipients'); - } - foreach ($recipients as $recipient) { - if (!\is_string($recipient) || $recipient === '') { - throw new \InvalidArgumentException('Token audience recipients must be non-empty strings'); - } - } - - $now = time(); - - return $this->sign([ - ...$claims, - Claim::Issuer->value => $this->issuer, - Claim::Audience->value => $audience, - Claim::IssuedAt->value => $now, - Claim::Expiration->value => $now + $duration, - ]); - } -} diff --git a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php b/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php deleted file mode 100644 index 2af278c8f..000000000 --- a/packages/auth/src/Auth/Issuers/Symmetric/RefreshToken.php +++ /dev/null @@ -1,78 +0,0 @@ - $scopes Granted scopes; joined into the space-delimited "scope" claim when non-empty. - * @param string|null $jti The "jti" claim; a random identifier is generated when null. - * @param array $claims Additional claims to merge into the payload. - * - * @throws \Exception When signing fails. - */ - public function issue( - string $subject, - string $audience, - string $clientId, - int $duration, - array $scopes = [], - ?string $jti = null, - array $claims = [], - ): string { - $now = time(); - - // "scope" is issuer-controlled; drop any caller-supplied value so it - // cannot be injected through $claims when $scopes is empty. - unset($claims[Claim::Scope->value]); - - $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 ($scopes !== []) { - $claims[Claim::Scope->value] = implode(' ', $scopes); - } - - return $this->sign($claims); - } -} diff --git a/packages/auth/src/Auth/OAuth2/AuthorizationDetails.php b/packages/auth/src/Auth/OAuth2/AuthorizationDetails.php deleted file mode 100644 index f505ea437..000000000 --- a/packages/auth/src/Auth/OAuth2/AuthorizationDetails.php +++ /dev/null @@ -1,133 +0,0 @@ -> - */ - private readonly array $entries; - - /** - * Accepts a raw `authorization_details` value. Anything that is not a list - * of objects contributes nothing, so callers need not pre-validate. - */ - public function __construct(mixed $value) - { - $entries = []; - if (\is_array($value) && array_is_list($value)) { - foreach ($value as $entry) { - if (\is_array($entry)) { - $entries[] = $entry; - } - } - } - - $this->entries = $entries; - } - - /** - * Whether an entry of $type lists $value in its $field. $field is an - * array-valued field name — an RFC 9396 common field (AuthorizationDetail's - * Locations, Actions, Datatypes, Privileges) or one a type defines itself. - * RFC 9396 has no wildcard, so a caller that treats one identifier as - * matching every value passes it as $wildcard to opt that field in. - */ - public function grants(string $type, string $value, string $field, ?string $wildcard = null): bool - { - if ($type === '' || $value === '' || $field === '') { - return false; - } - - foreach ($this->entries as $entry) { - if (($entry[AuthorizationDetail::Type->value] ?? '') !== $type) { - continue; - } - - $values = $entry[$field] ?? []; - if (!\is_array($values)) { - continue; - } - if (!array_is_list($values)) { - continue; - } - - if (\in_array($value, $values, true)) { - return true; - } - - if ($wildcard !== null && \in_array($wildcard, $values, true)) { - return true; - } - } - - return false; - } - - /** - * Narrow $field of every entry to the values $resolver allows for its type. - * A null result leaves the entry untouched; an empty result drops it. The - * result only ever narrows: a value the entry did not already list is - * discarded, so a resolver cannot widen the original grant. An entry that - * lists $wildcard is the exception: the resolver's result is taken as the - * expansion of the wildcard, as with grants(). - * - * @param callable(string $type, list $values): ?array $resolver - */ - public function restrict(string $field, callable $resolver, ?string $wildcard = null): self - { - $entries = []; - foreach ($this->entries as $entry) { - $type = $entry[AuthorizationDetail::Type->value] ?? ''; - if (!\is_string($type) || $type === '' || $field === '') { - $entries[] = $entry; - continue; - } - - $values = $entry[$field] ?? []; - $values = \is_array($values) && array_is_list($values) - ? array_values(array_filter($values, \is_string(...))) - : []; - - $allowed = $resolver($type, $values); - if ($allowed === null) { - $entries[] = $entry; - continue; - } - - $allowed = array_filter($allowed, \is_string(...)); - if ($wildcard === null || !\in_array($wildcard, $values, true)) { - $allowed = array_intersect($values, $allowed); - } - $allowed = array_values($allowed); - if ($allowed === []) { - continue; - } - - $entry[$field] = $allowed; - $entries[] = $entry; - } - - return new self($entries); - } - - /** - * @return list> - */ - public function toArray(): array - { - return $this->entries; - } -} diff --git a/packages/auth/src/Auth/OAuth2/ClientIdMetadataDocument.php b/packages/auth/src/Auth/OAuth2/ClientIdMetadataDocument.php deleted file mode 100644 index aa158ccfe..000000000 --- a/packages/auth/src/Auth/OAuth2/ClientIdMetadataDocument.php +++ /dev/null @@ -1,280 +0,0 @@ - $metadata - * @param list $grantTypes - * @param list $responseTypes - */ - private function __construct( - private readonly ClientIdentifierUrl $clientId, - private readonly array $metadata, - private readonly string $tokenEndpointAuthMethod, - private readonly array $grantTypes, - private readonly array $responseTypes, - private readonly RedirectUris $redirectUris, - ) {} - - /** - * Parse a JSON Client ID Metadata Document. - * - * @throws InvalidClientMetadataException - */ - public static function fromJson(ClientIdentifierUrl $clientId, string $json): self - { - try { - $decoded = json_decode($json, flags: \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - throw new InvalidClientMetadataException('Client ID Metadata Document is not valid JSON.'); - } - - if (!$decoded instanceof \stdClass) { - throw new InvalidClientMetadataException('Client ID Metadata Document must be a JSON object.'); - } - - /** @var array $metadata */ - $metadata = self::normalizeJsonValue($decoded); - - return self::fromArray($clientId, $metadata); - } - - /** - * Validate an already decoded Client ID Metadata Document. - * - * Unknown metadata is preserved so extension specifications can be used - * without requiring a package release. - * - * @param array $metadata - * @throws InvalidClientMetadataException - */ - public static function fromArray(ClientIdentifierUrl $clientId, array $metadata): self - { - if (($metadata['client_id'] ?? null) !== $clientId->toString()) { - throw new InvalidClientMetadataException('client_id must exactly match the Client Identifier URL.'); - } - - foreach (['client_secret', 'client_secret_expires_at'] as $property) { - if (\array_key_exists($property, $metadata)) { - throw new InvalidClientMetadataException("Client ID Metadata Documents must not contain {$property}."); - } - } - - // RFC 7591 defaults an omitted method to client_secret_basic. Since a - // metadata document cannot establish a shared secret, clients need to - // opt into a compatible method such as none or private_key_jwt. - $tokenEndpointAuthMethod = $metadata['token_endpoint_auth_method'] ?? null; - if (!\is_string($tokenEndpointAuthMethod) || $tokenEndpointAuthMethod === '') { - throw new InvalidClientMetadataException('token_endpoint_auth_method must be explicitly declared.'); - } - - // All client_secret_* methods require a pre-established shared secret, - // which a publicly fetched metadata document cannot securely provide. - if (str_starts_with($tokenEndpointAuthMethod, 'client_secret_')) { - throw new InvalidClientMetadataException('token_endpoint_auth_method must not use a shared symmetric secret.'); - } - - $grantTypes = self::stringList($metadata, 'grant_types', ['authorization_code']); - $responseTypes = self::stringList($metadata, 'response_types', ['code']); - $redirectUris = self::stringList($metadata, 'redirect_uris', []); - - foreach ($redirectUris as $redirectUri) { - self::validateRedirectUri($redirectUri); - } - - self::stringList($metadata, 'contacts', []); - $postLogoutRedirectUris = self::stringList($metadata, 'post_logout_redirect_uris', []); - foreach ($postLogoutRedirectUris as $redirectUri) { - self::validateRedirectUri($redirectUri); - } - - foreach (self::STRING_METADATA_PROPERTIES as $property) { - if (\array_key_exists($property, $metadata) && !\is_string($metadata[$property])) { - throw new InvalidClientMetadataException("{$property} must be a string."); - } - } - - if (\array_key_exists('jwks', $metadata) && \array_key_exists('jwks_uri', $metadata)) { - throw new InvalidClientMetadataException('jwks and jwks_uri must not both be present.'); - } - - if (\array_key_exists('jwks', $metadata)) { - self::validateJwks($metadata['jwks']); - } - - return new self( - $clientId, - $metadata, - $tokenEndpointAuthMethod, - $grantTypes, - $responseTypes, - RedirectUris::from($redirectUris), - ); - } - - public function clientId(): ClientIdentifierUrl - { - return $this->clientId; - } - - public function tokenEndpointAuthMethod(): string - { - return $this->tokenEndpointAuthMethod; - } - - /** - * @return list - */ - public function grantTypes(): array - { - return $this->grantTypes; - } - - /** - * @return list - */ - public function responseTypes(): array - { - return $this->responseTypes; - } - - public function redirectUris(): RedirectUris - { - return $this->redirectUris; - } - - public function get(string $property, mixed $default = null): mixed - { - return \array_key_exists($property, $this->metadata) - ? $this->metadata[$property] - : $default; - } - - /** - * @return array - */ - public function toArray(): array - { - return $this->metadata; - } - - /** - * @param array $metadata - * @param list $default - * @return list - */ - private static function stringList(array $metadata, string $property, array $default): array - { - if (!\array_key_exists($property, $metadata)) { - return $default; - } - - $values = $metadata[$property]; - if (!\is_array($values) || !array_is_list($values)) { - throw new InvalidClientMetadataException("{$property} must be a list of strings."); - } - - foreach ($values as $value) { - if (!\is_string($value) || $value === '') { - throw new InvalidClientMetadataException("{$property} must contain non-empty strings."); - } - } - - /** @var list $values */ - return $values; - } - - private static function validateRedirectUri(string $uri): void - { - $parts = parse_url($uri); - - if (!\is_array($parts) || empty($parts['scheme']) || isset($parts['fragment'])) { - throw new InvalidClientMetadataException('redirect URIs must be absolute URIs without fragments.'); - } - } - - private static function validateJwks(mixed $jwks): void - { - if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys']) || !array_is_list($jwks['keys'])) { - throw new InvalidClientMetadataException('jwks must be a JSON Web Key Set object.'); - } - - foreach ($jwks['keys'] as $jwk) { - if (!\is_array($jwk) || array_is_list($jwk)) { - throw new InvalidClientMetadataException('jwks must contain JSON Web Key objects.'); - } - - foreach (self::PRIVATE_JWK_PARAMETERS as $parameter) { - if (\array_key_exists($parameter, $jwk)) { - throw new InvalidClientMetadataException('jwks must not contain private or symmetric key material.'); - } - } - } - } - - private static function normalizeJsonValue(mixed $value): mixed - { - if ($value instanceof \stdClass) { - $normalized = []; - foreach (get_object_vars($value) as $key => $entry) { - $normalized[$key] = self::normalizeJsonValue($entry); - } - - return $normalized; - } - - if (\is_array($value)) { - return array_map(self::normalizeJsonValue(...), $value); - } - - return $value; - } -} diff --git a/packages/auth/src/Auth/OAuth2/ClientIdentifierUrl.php b/packages/auth/src/Auth/OAuth2/ClientIdentifierUrl.php deleted file mode 100644 index 74afd9782..000000000 --- a/packages/auth/src/Auth/OAuth2/ClientIdentifierUrl.php +++ /dev/null @@ -1,87 +0,0 @@ -value; - } - - public function host(): string - { - return $this->host; - } -} diff --git a/packages/auth/src/Auth/OAuth2/InvalidClientMetadataException.php b/packages/auth/src/Auth/OAuth2/InvalidClientMetadataException.php deleted file mode 100644 index 5bdeced60..000000000 --- a/packages/auth/src/Auth/OAuth2/InvalidClientMetadataException.php +++ /dev/null @@ -1,7 +0,0 @@ -id; - } - - /** - * Serialize the value to the RFC 9126 request_uri parameter format. - */ - public function requestUri(): string - { - return "{$this->prefix}{$this->id}"; - } -} diff --git a/packages/auth/src/Auth/OAuth2/Prompts.php b/packages/auth/src/Auth/OAuth2/Prompts.php deleted file mode 100644 index dd83b0105..000000000 --- a/packages/auth/src/Auth/OAuth2/Prompts.php +++ /dev/null @@ -1,92 +0,0 @@ - - */ - private readonly array $prompts; - - /** - * @param list $prompts - */ - private function __construct(array $prompts) - { - if (\in_array(Prompt::None, $prompts, true) && \count($prompts) > 1) { - throw new InvalidPromptException('prompt=none cannot be combined with other prompt values.'); - } - - $this->prompts = $prompts; - } - - /** - * Parse the OIDC prompt request parameter. - * - * Empty input means no prompt preference was requested. Duplicate prompt - * values are collapsed while preserving the first-seen order. - * - * @throws InvalidPromptException - */ - public static function fromString(string $prompt): self - { - if ($prompt === '') { - return new self([]); - } - - $values = array_values(array_filter(explode(' ', $prompt), static fn(string $value): bool => $value !== '')); - $prompts = []; - - foreach ($values as $value) { - $promptValue = Prompt::tryFrom($value); - - if ($promptValue === null) { - throw new InvalidPromptException("Invalid prompt value '{$value}'."); - } - - if (!\in_array($promptValue, $prompts, true)) { - $prompts[] = $promptValue; - } - } - - return new self($prompts); - } - - /** - * Check whether a prompt value was requested. - */ - public function contains(Prompt $prompt): bool - { - return \in_array($prompt, $this->prompts, true); - } - - /** - * Return prompt values for persistence or API boundaries. - * - * @return list - */ - public function toArray(): array - { - return array_map(static fn(Prompt $prompt): string => $prompt->value, $this->prompts); - } - - /** - * Serialize prompt values back to the OIDC space-delimited format. - */ - public function toString(): string - { - return implode(' ', $this->toArray()); - } -} diff --git a/packages/auth/src/Auth/OAuth2/RedirectUris.php b/packages/auth/src/Auth/OAuth2/RedirectUris.php deleted file mode 100644 index 71ede0f4d..000000000 --- a/packages/auth/src/Auth/OAuth2/RedirectUris.php +++ /dev/null @@ -1,125 +0,0 @@ - $uris - */ - private function __construct(private readonly array $uris) {} - - /** - * Wrap a client's stored registered URIs. Non-string and empty entries - * are ignored rather than rejected: the list is existing data, not - * boundary input, and a malformed entry should never match anything. - * - * @param array $uris - */ - public static function from(array $uris): self - { - $filtered = []; - - foreach ($uris as $uri) { - if (\is_string($uri) && $uri !== '') { - $filtered[] = $uri; - } - } - - return new self($filtered); - } - - /** - * True when $presented exactly matches a registered URI, or — with - * $allowLoopback enabled — matches a registered http loopback - * URI on everything except the port. Enable the variance for public - * clients only (RFC 8252 Sections 7.3 and 8.4). - */ - public function matches(string $presented, bool $allowLoopback = false): bool - { - if ($presented === '') { - return false; - } - - if (\in_array($presented, $this->uris, true)) { - return true; - } - - if (!$allowLoopback) { - return false; - } - - $presentedParts = $this->loopbackParts($presented); - - if ($presentedParts === null) { - return false; - } - - foreach ($this->uris as $registered) { - if ($presentedParts === $this->loopbackParts($registered)) { - return true; - } - } - - return false; - } - - /** - * @return list - */ - public function toArray(): array - { - return $this->uris; - } - - /** - * Parse a URI into its port-insensitive comparison parts when it is an - * http loopback URI (RFC 8252 Section 7.3); null otherwise. - * - * Loopback hosts are an exact allowlist, so lookalikes such as - * `localhost.evil.com` never qualify. URIs carrying userinfo or a - * fragment fall back to exact matching only. - * - * @return array{host: string, path: string, query: string}|null - */ - private function loopbackParts(string $uri): ?array - { - $parts = parse_url($uri); - - if (!\is_array($parts)) { - return null; - } - - $host = strtolower((string) ($parts['host'] ?? '')); - - if (strtolower((string) ($parts['scheme'] ?? '')) !== 'http' - || !\in_array($host, self::LOOPBACK_HOSTS, true) - || isset($parts['user']) - || isset($parts['pass']) - || isset($parts['fragment'])) { - return null; - } - - return [ - 'host' => $host, - 'path' => ($parts['path'] ?? '') === '' ? '/' : $parts['path'], - 'query' => $parts['query'] ?? '', - ]; - } -} diff --git a/packages/auth/src/Auth/OAuth2/ResourceIndicators.php b/packages/auth/src/Auth/OAuth2/ResourceIndicators.php deleted file mode 100644 index 29a9910f5..000000000 --- a/packages/auth/src/Auth/OAuth2/ResourceIndicators.php +++ /dev/null @@ -1,130 +0,0 @@ - - */ - private readonly array $resources; - - /** - * @param array $resources - */ - private function __construct(array $resources) - { - if ($resources !== array_values($resources)) { - throw new InvalidResourceException('resources must be a list of absolute URIs.'); - } - - $seen = []; - - foreach ($resources as $resource) { - switch (true) { - case !\is_string($resource) || $resource === '': - throw new InvalidResourceException('resource must be a non-empty absolute URI.'); - - 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): - throw new InvalidResourceException('resources must not contain duplicates.'); - } - - $seen[] = $resource; - } - - /** @var list $resources */ - $this->resources = $resources; - } - - /** - * @param string|array|null $value - * @param string|null $audience Compatibility alias for a single resource indicator. - * - * @throws InvalidResourceException - */ - public static function from(string|array|null $value, ?string $audience = null): self - { - if ($value === null || $value === '') { - $value = []; - } - - $resources = \is_array($value) ? $value : [$value]; - $normalized = []; - - foreach ($resources as $resource) { - if (!\in_array($resource, $normalized, true)) { - $normalized[] = $resource; - } - } - - $resources = new self($normalized); - - if ($audience === null || $audience === '') { - return $resources; - } - - $audienceResource = new self([$audience]); - if ($resources->resources === []) { - return $audienceResource; - } - - if (!$audienceResource->isSubsetOf($resources)) { - throw new InvalidResourceException('audience must match one of the resource values when both parameters are provided.'); - } - - return $resources; - } - - /** - * Requested resources must be a subset of previously granted resources; - * use this on refresh and token requests per RFC 8707 Section 2.2. - */ - public function isSubsetOf(self $granted): bool - { - return array_diff($this->resources, $granted->resources) === []; - } - - public function equals(self $resources): bool - { - $left = $this->resources; - $right = $resources->resources; - sort($left, \SORT_STRING); - sort($right, \SORT_STRING); - - return $left === $right; - } - - /** - * @return list - */ - public function audience(string $defaultAudience): array - { - if ($this->resources === []) { - return [$defaultAudience]; - } - - return $this->resources; - } - - /** - * @return list - */ - public function toArray(): array - { - return $this->resources; - } - - private function isValid(string $resource): bool - { - $parts = parse_url($resource); - - return \is_array($parts) - && isset($parts['scheme']) - && \in_array(strtolower($parts['scheme']), ['http', 'https'], true) - && !isset($parts['fragment']) - && !empty($parts['host']); - } -} diff --git a/packages/auth/src/Auth/Proof.php b/packages/auth/src/Auth/Proof.php deleted file mode 100644 index eba80fed0..000000000 --- a/packages/auth/src/Auth/Proof.php +++ /dev/null @@ -1,51 +0,0 @@ -hash = $hash; - - return $this; - } - - /** - * Get current hash - */ - public function getHash(): Hash - { - return $this->hash; - } - - /** - * Generate a proof - */ - abstract public function generate(): string; - - /** - * Hash a proof - */ - public function hash(string $proof): string - { - return $this->hash->hash($proof); - } - - /** - * Verify a proof - */ - public function verify(string $proof, string $hash): bool - { - return $this->hash->verify($proof, $hash); - } -} diff --git a/packages/auth/src/Auth/Proofs/Code.php b/packages/auth/src/Auth/Proofs/Code.php deleted file mode 100644 index 4933982a0..000000000 --- a/packages/auth/src/Auth/Proofs/Code.php +++ /dev/null @@ -1,65 +0,0 @@ -length = $length; - } - - /** - * Get the code length - */ - public function getLength(): int - { - return $this->length; - } - - /** - * Set the code length - * - * - * @throws \Exception - */ - public function setLength(int $length): static - { - if ($length <= 0) { - throw new \Exception('Code length must be greater than 0'); - } - - $this->length = $length; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function generate(): string - { - $value = ''; - - for ($i = 0; $i < $this->length; $i++) { - $value .= random_int(0, 9); - } - - return $value; - } -} diff --git a/packages/auth/src/Auth/Proofs/Password.php b/packages/auth/src/Auth/Proofs/Password.php deleted file mode 100644 index 3318d0fd4..000000000 --- a/packages/auth/src/Auth/Proofs/Password.php +++ /dev/null @@ -1,192 +0,0 @@ - - */ - protected array $hashes = []; - - protected int $defaultLength = 16; - - protected string $defaultCharset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'; - - /** - * @param array $hashes - * - * @throws \Exception - */ - public function __construct(array $hashes = []) - { - parent::__construct(); - - // Initialize default hashes if no hashes were provided - if ($hashes === []) { - $hashes = [ - self::ARGON2 => new Argon2(), - self::BCRYPT => new Bcrypt(), - self::SCRYPT => new Scrypt(), - self::SCRYPT_MODIFIED => new ScryptModified(), - self::SHA => new Sha(), - self::MD5 => new MD5(), - self::PHPASS => new PHPass(), - ]; - } - - $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]; - } - - /** - * Add a new hashing hash - */ - public function addHash(string $name, Hash $hash): static - { - $this->hashes[$name] = $hash; - - return $this; - } - - /** - * Remove a hashing hash - * - * - * @throws \Exception - */ - public function removeHash(string $name): static - { - if (! isset($this->hashes[$name])) { - throw new \Exception("Hash '{$name}' not found"); - } - - if ($this->hash === $this->hashes[$name]) { - throw new \Exception('Cannot remove current hash'); - } - - unset($this->hashes[$name]); - - return $this; - } - - /** - * Get a specific hashing hash by name - * - * - * @throws \Exception - */ - public function getHashByName(string $name): Hash - { - if (! isset($this->hashes[$name])) { - throw new \Exception("Hash '{$name}' not found"); - } - - return $this->hashes[$name]; - } - - /** - * Set password generation length - * - * - * @throws \Exception - */ - public function setLength(int $length): static - { - if ($length < 8) { - throw new \Exception('Password length must be at least 8 characters'); - } - $this->defaultLength = $length; - - return $this; - } - - /** - * Set password generation charset - * - * - * @throws \Exception - */ - public function setCharset(string $charset): static - { - if (\strlen($charset) < 10) { - throw new \Exception('Password charset must contain at least 10 characters'); - } - $this->defaultCharset = $charset; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function generate(): string - { - $password = ''; - $max = \strlen($this->defaultCharset) - 1; - - if ($max < 0) { - throw new \Exception('Password charset is empty'); - } - - for ($i = 0; $i < $this->defaultLength; $i++) { - $password .= $this->defaultCharset[random_int(0, $max)]; - } - - return $password; - } - - /** - * Create a hash instance by type - * - * @param string $type One of the supported hash types (ARGON2, BCRYPT, SCRYPT, SCRYPT_MODIFIED, SHA, MD5, PHPASS) - * @param array $options Optional parameters for hash configuration - * - * @throws \Exception - */ - public static function createHash(string $type, array $options = []): Hash - { - $hash = match ($type) { - self::ARGON2 => new Argon2(), - self::BCRYPT => new Bcrypt(), - self::SCRYPT => new Scrypt(), - self::SCRYPT_MODIFIED => new ScryptModified(), - self::SHA => new Sha(), - self::MD5 => new MD5(), - self::PHPASS => new PHPass(), - default => throw new \Exception("Unsupported hash type: {$type}") - }; - - $hash->setOptions($options); - - return $hash; - } -} diff --git a/packages/auth/src/Auth/Proofs/Phrase.php b/packages/auth/src/Auth/Proofs/Phrase.php deleted file mode 100644 index 041f8d374..000000000 --- a/packages/auth/src/Auth/Proofs/Phrase.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - private array $adjectives = ['Abundant', 'Adaptable', 'Adventurous', 'Affectionate', 'Agile', 'Amiable', 'Amazing', 'Ambitious', 'Amicable', 'Amusing', 'Astonishing', 'Attentive', 'Authentic', 'Awesome', 'Balanced', 'Beautiful', 'Bold', 'Brave', 'Bright', 'Bubbly', 'Calm', 'Capable', 'Charismatic', 'Charming', 'Cheerful', 'Clever', 'Colorful', 'Compassionate', 'Confident', 'Cooperative', 'Courageous', 'Courteous', 'Creative', 'Curious', 'Dazzling', 'Dedicated', 'Delightful', 'Determined', 'Diligent', 'Dynamic', 'Easygoing', 'Effervescent', 'Efficient', 'Elegant', 'Empathetic', 'Energetic', 'Enthusiastic', 'Exuberant', 'Faithful', 'Fantastic', 'Fearless', 'Flexible', 'Friendly', 'Fun-loving', 'Generous', 'Gentle', 'Genuine', 'Graceful', 'Gracious', 'Happy', 'Hardworking', 'Harmonious', 'Helpful', 'Honest', 'Hopeful', 'Humble', 'Imaginative', 'Impressive', 'Incredible', 'Inspiring', 'Intelligent', 'Joyful', 'Kind', 'Knowledgeable', 'Lively', 'Lovable', 'Lovely', 'Loyal', 'Majestic', 'Magnificent', 'Mindful', 'Modest', 'Passionate', 'Patient', 'Peaceful', 'Perseverant', 'Playful', 'Polite', 'Positive', 'Powerful', 'Practical', 'Precious', 'Proactive', 'Productive', 'Punctual', 'Quick-witted', 'Radiant', 'Reliable', 'Resilient', 'Resourceful', 'Respectful', 'Responsible', 'Sensitive', 'Serene', 'Sincere', 'Skillful', 'Soothing', 'Spirited', 'Splendid', 'Steadfast', 'Strong', 'Supportive', 'Sweet', 'Talented', 'Thankful', 'Thoughtful', 'Thriving', 'Tranquil', 'Trustworthy', 'Upbeat', 'Versatile', 'Vibrant', 'Vigilant', 'Warmhearted', 'Welcoming', 'Wholesome', 'Witty', 'Wonderful', 'Zealous']; - - /** @var array */ - 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']; - - /** - * Generate a proof - */ - public function generate(): string - { - $adjective = $this->adjectives[array_rand($this->adjectives)]; - $noun = $this->nouns[array_rand($this->nouns)]; - - return "{$adjective} {$noun}"; - } -} diff --git a/packages/auth/src/Auth/Proofs/Token.php b/packages/auth/src/Auth/Proofs/Token.php deleted file mode 100644 index d47cde35f..000000000 --- a/packages/auth/src/Auth/Proofs/Token.php +++ /dev/null @@ -1,60 +0,0 @@ -length = $length; - } - - /** - * {@inheritdoc} - */ - public function generate(): string - { - $bytesLength = max(1, (int) ceil($this->length / 2)); - $token = bin2hex(random_bytes($bytesLength)); - - return substr($token, 0, $this->length); - } - - /** - * Get the token length - */ - public function getLength(): int - { - return $this->length; - } - - /** - * Set the token length - * - * - * @throws \Exception - */ - public function setLength(int $length): static - { - if ($length <= 0) { - throw new \Exception('Token length must be greater than 0'); - } - - $this->length = $length; - - return $this; - } -} diff --git a/packages/auth/src/Auth/Store.php b/packages/auth/src/Auth/Store.php deleted file mode 100644 index b886c4d5a..000000000 --- a/packages/auth/src/Auth/Store.php +++ /dev/null @@ -1,86 +0,0 @@ - - */ - protected array $data = []; - - protected ?string $key = null; - - /** - * Get a property from the store - */ - public function getProperty(string $key, mixed $default = null): mixed - { - return $this->data[$key] ?? $default; - } - - /** - * Set a property in the store - */ - public function setProperty(string $key, mixed $value): static - { - $this->data[$key] = $value; - - return $this; - } - - /** - * Get the store key - */ - public function getKey(): ?string - { - return $this->key; - } - - /** - * Set the store key - */ - public function setKey(?string $key): static - { - $this->key = $key; - - return $this; - } - - /** - * Encode store data to base64 string - * - * - * @throws \JsonException - */ - public function encode(): string - { - $json = json_encode($this->data, JSON_THROW_ON_ERROR); - - return base64_encode($json); - } - - /** - * Decode base64 string and populate current store instance - */ - public function decode(string $data): static - { - try { - $decoded = base64_decode($data, true); - if ($decoded === false) { - return $this; - } - - $json = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); - if (\is_array($json)) { - foreach ($json as $key => $value) { - $this->setProperty($key, $value); - } - } - } catch (\JsonException) { - // Invalid JSON, return empty store - } - - return $this; - } -} diff --git a/packages/auth/src/Auth/Verifier.php b/packages/auth/src/Auth/Verifier.php deleted file mode 100644 index 90cbbb7a3..000000000 --- a/packages/auth/src/Auth/Verifier.php +++ /dev/null @@ -1,243 +0,0 @@ -|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 deleted file mode 100644 index 41c5cfab6..000000000 --- a/packages/auth/src/Auth/Verifiers/Asymmetric.php +++ /dev/null @@ -1,95 +0,0 @@ -|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 deleted file mode 100644 index b2e8933fc..000000000 --- a/packages/auth/src/Auth/Verifiers/Symmetric.php +++ /dev/null @@ -1,55 +0,0 @@ -|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 deleted file mode 100644 index 52c199983..000000000 --- a/packages/auth/src/Auth/Verifiers/VerificationException.php +++ /dev/null @@ -1,12 +0,0 @@ -argon2 = new Argon2(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->argon2->hash($password); - $this->assertNotEmpty($hash); - $this->assertStringStartsWith('$argon2id$', $hash); - $this->assertStringContainsString('m=' . $this->argon2->getOption('memory_cost'), $hash); - $this->assertStringContainsString('t=' . $this->argon2->getOption('time_cost'), $hash); - $this->assertStringContainsString('p=' . $this->argon2->getOption('threads'), $hash); - $this->assertTrue($this->argon2->verify($password, $hash)); - $this->assertFalse($this->argon2->verify('wrongpassword', $hash)); - } - - public function testValidMemoryCost(): void - { - $cost = PASSWORD_ARGON2_DEFAULT_MEMORY_COST + 1024; - $this->argon2->setMemoryCost($cost); - - // Test that the new memory cost is being used by verifying a hash - $password = 'test123'; - $hash = $this->argon2->hash($password); - $this->assertStringContainsString('m=' . $cost, $hash); - $this->assertTrue($this->argon2->verify($password, $hash)); - } - - public function testValidTimeCost(): void - { - $cost = PASSWORD_ARGON2_DEFAULT_TIME_COST + 1; - $this->argon2->setTimeCost($cost); - - // Test that the new time cost is being used by verifying a hash - $password = 'test123'; - $hash = $this->argon2->hash($password); - $this->assertStringContainsString('t=' . $cost, $hash); - $this->assertTrue($this->argon2->verify($password, $hash)); - } - - public function testValidThreads(): void - { - $threads = PASSWORD_ARGON2_DEFAULT_THREADS + 1; - $this->argon2->setThreads($threads); - - // Test that the new thread count is being used by verifying a hash - $password = 'test123'; - $hash = $this->argon2->hash($password); - $this->assertStringContainsString('p=' . $threads, $hash); - $this->assertTrue($this->argon2->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('argon2', $this->argon2->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/BcryptTest.php b/packages/auth/tests/Auth/Algorithms/BcryptTest.php deleted file mode 100644 index 6e6a7a3a0..000000000 --- a/packages/auth/tests/Auth/Algorithms/BcryptTest.php +++ /dev/null @@ -1,34 +0,0 @@ -bcrypt = new Bcrypt(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->bcrypt->hash($password); - - $this->assertNotEmpty($hash); - $this->assertStringStartsWith('$2y$', $hash); - $this->assertTrue($this->bcrypt->verify($password, $hash)); - $this->assertFalse($this->bcrypt->verify('wrongpassword', $hash)); - } - - public function testGetName(): void - { - $this->assertSame('bcrypt', $this->bcrypt->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/MD5Test.php b/packages/auth/tests/Auth/Algorithms/MD5Test.php deleted file mode 100644 index ee29bb029..000000000 --- a/packages/auth/tests/Auth/Algorithms/MD5Test.php +++ /dev/null @@ -1,73 +0,0 @@ -md5 = new MD5(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->md5->hash($password); - - $this->assertNotEmpty($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)); - } - - public function testMultipleHashes(): void - { - $passwords = ['test123', 'password123', '!@#$%^&*()']; - - foreach ($passwords as $password) { - $hash = $this->md5->hash($password); - $this->assertSame(md5($password), $hash); - $this->assertTrue($this->md5->verify($password, $hash)); - } - } - - public function testEmptyString(): void - { - $password = ''; - $hash = $this->md5->hash($password); - - $this->assertSame(md5(''), $hash); - $this->assertTrue($this->md5->verify($password, $hash)); - } - - public function testSpecialCharacters(): void - { - $password = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - $hash = $this->md5->hash($password); - - $this->assertSame(md5($password), $hash); - $this->assertTrue($this->md5->verify($password, $hash)); - } - - public function testUnicodeCharacters(): void - { - $password = 'Hello 世界'; - $hash = $this->md5->hash($password); - - $this->assertSame(md5($password), $hash); - $this->assertTrue($this->md5->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('md5', $this->md5->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/PHPassTest.php b/packages/auth/tests/Auth/Algorithms/PHPassTest.php deleted file mode 100644 index 93a6f1a42..000000000 --- a/packages/auth/tests/Auth/Algorithms/PHPassTest.php +++ /dev/null @@ -1,91 +0,0 @@ -phpass = new PHPass(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->phpass->hash($password); - - $this->assertNotEmpty($hash); - $this->assertTrue($this->phpass->verify($password, $hash)); - $this->assertFalse($this->phpass->verify('wrongpassword', $hash)); - } - - public function testIterationCount(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->phpass->setIterationCount(3); // Should throw exception for too low iteration count - } - - public function testValidIterationCount(): void - { - $this->phpass->setIterationCount(8); - - // Test that the new iteration count is being used by verifying a hash - $password = 'test123'; - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testPortableHashes(): void - { - // Test with portable hashes enabled - $this->phpass->setPortableHashes(true); - $password = 'test123'; - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - - // Test with portable hashes disabled - $this->phpass->setPortableHashes(false); - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testSpecialCharacters(): void - { - $password = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testUnicodeCharacters(): void - { - $password = 'Hello 世界'; - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testEmptyString(): void - { - $password = ''; - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testLongPassword(): void - { - $password = str_repeat('a', 1000); - $hash = $this->phpass->hash($password); - $this->assertTrue($this->phpass->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('phpass', $this->phpass->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/PlaintextTest.php b/packages/auth/tests/Auth/Algorithms/PlaintextTest.php deleted file mode 100644 index 350d456ad..000000000 --- a/packages/auth/tests/Auth/Algorithms/PlaintextTest.php +++ /dev/null @@ -1,61 +0,0 @@ -plaintext = new Plaintext(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->plaintext->hash($password); - - $this->assertNotEmpty($hash); - $this->assertSame($password, $hash); - $this->assertTrue($this->plaintext->verify($password, $hash)); - $this->assertFalse($this->plaintext->verify('wrongpassword', $hash)); - } - - public function testSpecialCharacters(): void - { - $password = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - $hash = $this->plaintext->hash($password); - - $this->assertSame($password, $hash); - $this->assertTrue($this->plaintext->verify($password, $hash)); - } - - public function testUnicodeCharacters(): void - { - $password = 'Hello 世界'; - $hash = $this->plaintext->hash($password); - - $this->assertSame($password, $hash); - $this->assertTrue($this->plaintext->verify($password, $hash)); - } - - public function testEmptyString(): void - { - $password = ''; - $hash = $this->plaintext->hash($password); - - $this->assertSame($password, $hash); - $this->assertTrue($this->plaintext->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('plaintext', $this->plaintext->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php b/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php deleted file mode 100644 index e49a311f5..000000000 --- a/packages/auth/tests/Auth/Algorithms/ScryptModifiedTest.php +++ /dev/null @@ -1,45 +0,0 @@ -scryptModified = new ScryptModified(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->scryptModified->hash($password); - - $this->assertNotEmpty($hash); - $this->assertTrue($this->scryptModified->verify($password, $hash)); - $this->assertFalse($this->scryptModified->verify('wrongpassword', $hash)); - } - - public function testCustomOptions(): void - { - $this->scryptModified->setSalt(base64_encode('custom-salt')) - ->setSaltSeparator(base64_encode('custom-separator')) - ->setSignerKey(base64_encode('custom-signer-key')); - - $password = 'test123'; - $hash = $this->scryptModified->hash($password); - - $this->assertTrue($this->scryptModified->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('scryptMod', $this->scryptModified->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/ScryptTest.php b/packages/auth/tests/Auth/Algorithms/ScryptTest.php deleted file mode 100644 index 5a65eb34a..000000000 --- a/packages/auth/tests/Auth/Algorithms/ScryptTest.php +++ /dev/null @@ -1,56 +0,0 @@ -scrypt = new Scrypt(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->scrypt->hash($password); - - $this->assertNotEmpty($hash); - $this->assertTrue($this->scrypt->verify($password, $hash)); - $this->assertFalse($this->scrypt->verify('wrongpassword', $hash)); - } - - public function testDefaultSaltIsGenerated(): void - { - $salt = $this->scrypt->getOption('salt'); - - $this->assertIsString($salt); - $this->assertNotSame('', $salt); - $this->assertNotSame($salt, (new Scrypt())->getOption('salt')); - } - - public function testCustomOptions(): void - { - $this->scrypt->setCpuCost(16) - ->setMemoryCost(15) - ->setParallelCost(2) - ->setLength(128) - ->setSalt('custom-salt'); - - $password = 'test123'; - $hash = $this->scrypt->hash($password); - - $this->assertTrue($this->scrypt->verify($password, $hash)); - } - - public function testGetName(): void - { - $this->assertSame('scrypt', $this->scrypt->getName()); - } -} diff --git a/packages/auth/tests/Auth/Algorithms/ShaTest.php b/packages/auth/tests/Auth/Algorithms/ShaTest.php deleted file mode 100644 index dd146e622..000000000 --- a/packages/auth/tests/Auth/Algorithms/ShaTest.php +++ /dev/null @@ -1,48 +0,0 @@ -sha = new Sha(); - } - - public function testHash(): void - { - $password = 'test123'; - $hash = $this->sha->hash($password); - - $this->assertNotEmpty($hash); - $this->assertTrue($this->sha->verify($password, $hash)); - $this->assertFalse($this->sha->verify('wrongpassword', $hash)); - } - - public function testCustomVersion(): void - { - $this->sha->setVersion(Sha::SHA256); - $password = 'test123'; - $hash = $this->sha->hash($password); - - $this->assertTrue($this->sha->verify($password, $hash)); - } - - public function testInvalidVersion(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->sha->setVersion('invalid-version'); - } - - public function testGetName(): void - { - $this->assertSame('sha', $this->sha->getName()); - } -} diff --git a/packages/auth/tests/Auth/HashTest.php b/packages/auth/tests/Auth/HashTest.php deleted file mode 100644 index a20460f30..000000000 --- a/packages/auth/tests/Auth/HashTest.php +++ /dev/null @@ -1,83 +0,0 @@ -hash = new class extends Hash { - public function hash(string $value): string - { - return 'hashed_' . $value; - } - - public function verify(string $value, string $hash): bool - { - return $hash === 'hashed_' . $value; - } - - public function getName(): string - { - return 'test_hash'; - } - }; - } - - public function testSetAndGetOption(): void - { - $this->hash->setOption('key1', 'value1'); - $this->assertEquals('value1', $this->hash->getOption('key1')); - $this->assertNull($this->hash->getOption('nonexistent')); - $this->assertEquals('default', $this->hash->getOption('nonexistent', 'default')); - } - - public function testSetOptions(): void - { - $options = [ - 'key1' => 'value1', - 'key2' => 'value2', - 'key3' => ['nested' => 'value'], - ]; - - $this->hash->setOptions($options); - - // Verify all options were set - $this->assertSame($options, $this->hash->getOptions()); - - // Verify individual options - foreach ($options as $key => $value) { - $this->assertEquals($value, $this->hash->getOption($key)); - } - } - - public function testGetOptions(): void - { - $options = [ - 'key1' => 'value1', - 'key2' => 'value2', - ]; - - $this->hash->setOptions($options); - $this->assertSame($options, $this->hash->getOptions()); - } - - public function testMethodChaining(): void - { - $result = $this->hash - ->setOption('key1', 'value1') - ->setOptions(['key2' => 'value2']); - - $this->assertInstanceOf(Hash::class, $result); - $this->assertEquals('value1', $this->hash->getOption('key1')); - $this->assertEquals('value2', $this->hash->getOption('key2')); - } -} diff --git a/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php b/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php deleted file mode 100644 index 0407da946..000000000 --- a/packages/auth/tests/Auth/Issuers/Asymmetric/AccessTokenTest.php +++ /dev/null @@ -1,170 +0,0 @@ -publicKey] = AccessToken::generateKeyPair(); - - $this->accessToken = new AccessToken( - $privateKey, - $this->publicKey, - 'https://example.com/v1/oauth2/test', - ); - } - - /** - * @return array - */ - private function decodeSegment(string $segment): array - { - /** @var array $claims */ - $claims = json_decode(base64_decode(strtr($segment, '-_', '+/')), true); - - return $claims; - } - - public function testHeaderType(): void - { - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600); - $header = $this->decodeSegment(explode('.', $token)[0]); - - // RFC 9068 §2.1: access tokens carry the "at+jwt" media type. - $this->assertEquals('at+jwt', $header['typ']); - $this->assertEquals('RS256', $header['alg']); - $this->assertEquals($this->accessToken->getKeyId(), $header['kid']); - } - - public function testClaims(): void - { - $before = time(); - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read', 'write']); - $after = time(); - - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - $this->assertEquals(['https://api.example.com'], $claims['aud']); - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('client-abc', $claims['client_id']); - $this->assertEquals('read write', $claims['scope']); - $this->assertEquals(1000, $claims['auth_time']); - $this->assertGreaterThanOrEqual($before, $claims['iat']); - $this->assertLessThanOrEqual($after, $claims['iat']); - $this->assertEquals($claims['iat'] + 3600, $claims['exp']); - $jti = $claims['jti']; - \assert(\is_string($jti)); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti); - } - - public function testAudienceClaim(): void - { - $audience = ['https://api.example.com', 'https://mcp.example.com']; - $token = $this->accessToken->issue('user-123', $audience, 'client-abc', 1000, 3600); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals($audience, $claims['aud']); - } - - public function testEmptyAudienceIsRejected(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('audience must contain at least one resource server identifier.'); - - $this->accessToken->issue('user-123', [], 'client-abc', 1000, 3600); - } - - public function testSignatureIsValid(): void - { - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600); - - $parts = explode('.', $token); - $result = openssl_verify( - $parts[0] . '.' . $parts[1], - base64_decode(strtr($parts[2], '-_', '+/')), - $this->publicKey, - OPENSSL_ALGO_SHA256, - ); - - $this->assertSame(1, $result); - } - - public function testScopeOmittedWhenEmpty(): void - { - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertArrayNotHasKey('scope', $claims); - } - - public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void - { - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, [], null, [ - 'scope' => 'admin', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertArrayNotHasKey('scope', $claims); - } - - public function testScopeCannotBeOverriddenViaClaims(): void - { - $token = $this->accessToken->issue('user-123', ['https://api.example.com'], 'client-abc', 1000, 3600, ['read'], null, [ - 'scope' => 'admin', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('read', $claims['scope']); - } - - public function testJtiIsGeneratedAndUnique(): void - { - $first = $this->decodeSegment(explode('.', $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600))[1]); - $second = $this->decodeSegment(explode('.', $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600))[1]); - - $this->assertNotEquals($first['jti'], $second['jti']); - } - - public function testCustomJti(): void - { - $token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], 'fixed-jti'); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('fixed-jti', $claims['jti']); - } - - public function testAdditionalClaims(): void - { - $token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], null, [ - 'tokenId' => 'identity-row-1', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('identity-row-1', $claims['tokenId']); - } - - public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void - { - $token = $this->accessToken->issue('user-123', ['aud'], 'client', 1000, 3600, [], null, [ - 'sub' => 'attacker', - 'iss' => 'https://evil.example.com', - 'client_id' => 'evil', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - $this->assertEquals('client', $claims['client_id']); - } -} diff --git a/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php b/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php deleted file mode 100644 index 9bd0d8a72..000000000 --- a/packages/auth/tests/Auth/Issuers/Asymmetric/IdTokenTest.php +++ /dev/null @@ -1,268 +0,0 @@ -privateKey, $this->publicKey] = IdToken::generateKeyPair(); - - $this->idToken = new IdToken( - $this->privateKey, - $this->publicKey, - 'https://example.com/v1/oauth2/test', - ); - } - - /** - * Decode a JWT segment from base64url JSON into an array. - * - * @return array - */ - private function decodeSegment(string $segment): array - { - $json = base64_decode(strtr($segment, '-_', '+/')); - - /** @var array $claims */ - $claims = json_decode($json, true); - - return $claims; - } - - public function testIssueStructure(): void - { - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); - - $parts = explode('.', $token); - $this->assertCount(3, $parts); - - $header = $this->decodeSegment($parts[0]); - $this->assertEquals('JWT', $header['typ']); - $this->assertEquals('RS256', $header['alg']); - $this->assertEquals($this->idToken->getKeyId(), $header['kid']); - } - - public function testIssueClaims(): void - { - $before = time(); - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); - $after = time(); - - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('client-abc', $claims['aud']); - $this->assertEquals(1000, $claims['auth_time']); - $this->assertGreaterThanOrEqual($before, $claims['iat']); - $this->assertLessThanOrEqual($after, $claims['iat']); - $this->assertEquals($claims['iat'] + 3600, $claims['exp']); - - // Optional claims absent by default - $this->assertArrayNotHasKey('nonce', $claims); - $this->assertArrayNotHasKey('at_hash', $claims); - $this->assertArrayNotHasKey('c_hash', $claims); - } - - public function testSignatureIsValid(): void - { - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600); - - $parts = explode('.', $token); - $signingInput = $parts[0] . '.' . $parts[1]; - $signature = base64_decode(strtr($parts[2], '-_', '+/')); - - $result = openssl_verify( - $signingInput, - $signature, - $this->publicKey, - OPENSSL_ALGO_SHA256, - ); - - $this->assertSame(1, $result); - } - - public function testNonceClaim(): void - { - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, 'n-0S6_WzA2Mj'); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('n-0S6_WzA2Mj', $claims['nonce']); - } - - public function testAtHashAndCHash(): void - { - $accessToken = 'access-token-value'; - $code = 'authorization-code-value'; - - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, $accessToken, $code); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $expectedAtHash = $this->expectedLeftHalfHash($accessToken); - $expectedCHash = $this->expectedLeftHalfHash($code); - - $this->assertEquals($expectedAtHash, $claims['at_hash']); - $this->assertEquals($expectedCHash, $claims['c_hash']); - } - - public function testHashClaimsCannotBeInjectedViaClaimsWhenAbsent(): void - { - // No nonce/accessToken/code passed, but a caller tries to smuggle them - // (e.g. a forged at_hash) through the additional claims array. - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ - 'nonce' => 'forged-nonce', - 'at_hash' => 'forged-at-hash', - 'c_hash' => 'forged-c-hash', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertArrayNotHasKey('nonce', $claims); - $this->assertArrayNotHasKey('at_hash', $claims); - $this->assertArrayNotHasKey('c_hash', $claims); - } - - public function testHashClaimsCannotBeOverriddenViaClaims(): void - { - $accessToken = 'access-token-value'; - $code = 'authorization-code-value'; - - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, 'real-nonce', $accessToken, $code, [ - 'nonce' => 'forged-nonce', - 'at_hash' => 'forged-at-hash', - 'c_hash' => 'forged-c-hash', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('real-nonce', $claims['nonce']); - $this->assertEquals($this->expectedLeftHalfHash($accessToken), $claims['at_hash']); - $this->assertEquals($this->expectedLeftHalfHash($code), $claims['c_hash']); - } - - public function testUnrepresentableClaimThrows(): void - { - // Invalid UTF-8 cannot be JSON-encoded; this must fail loudly rather - // than silently produce a token with an empty payload segment. - $this->expectException(\JsonException::class); - $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ - 'bad' => "\xB1\x31", - ]); - } - - public function testAdditionalClaims(): void - { - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ - 'email' => 'user@example.com', - 'email_verified' => true, - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('user@example.com', $claims['email']); - $this->assertTrue($claims['email_verified']); - } - - public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void - { - $token = $this->idToken->issue('user-123', 'client-abc', 1000, 3600, null, null, null, [ - 'sub' => 'attacker', - 'iss' => 'https://evil.example.com', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - } - - public function testKeyIdIsDeterministic(): void - { - $other = new IdToken($this->privateKey, $this->publicKey, 'https://example.com/v1/oauth2/test'); - - $this->assertSame($this->idToken->getKeyId(), $other->getKeyId()); - $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $this->idToken->getKeyId()); - } - - public function testCustomKeyId(): void - { - $idToken = new IdToken($this->privateKey, $this->publicKey, 'https://example.com', 'my-custom-kid'); - - $this->assertSame('my-custom-kid', $idToken->getKeyId()); - - $token = $idToken->issue('user-123', 'client-abc', 1000, 3600); - $header = $this->decodeSegment(explode('.', $token)[0]); - $this->assertEquals('my-custom-kid', $header['kid']); - } - - public function testGetPublicJwk(): void - { - $jwk = $this->idToken->getPublicJwk(); - - $this->assertEquals('RSA', $jwk['kty']); - $this->assertEquals('sig', $jwk['use']); - $this->assertEquals('RS256', $jwk['alg']); - $this->assertEquals($this->idToken->getKeyId(), $jwk['kid']); - $this->assertNotEmpty($jwk['n']); - $this->assertNotEmpty($jwk['e']); - // base64url: no padding, no +/ characters - $this->assertStringNotContainsString('=', $jwk['n']); - $this->assertStringNotContainsString('+', $jwk['n']); - $this->assertStringNotContainsString('/', $jwk['n']); - } - - public function testEmptyPrivateKeyThrows(): void - { - $this->expectException(\Exception::class); - new IdToken('', $this->publicKey, 'https://example.com'); - } - - public function testEmptyPublicKeyThrows(): void - { - $this->expectException(\Exception::class); - new IdToken($this->privateKey, '', 'https://example.com'); - } - - public function testEmptyIssuerThrows(): void - { - $this->expectException(\Exception::class); - new IdToken($this->privateKey, $this->publicKey, ''); - } - - public function testGenerateKeyPair(): void - { - [$privateKey, $publicKey] = IdToken::generateKeyPair(); - - $this->assertStringContainsString('PRIVATE KEY', $privateKey); - $this->assertStringContainsString('PUBLIC KEY', $publicKey); - - // The generated keys are usable for issuing and verifying a token. - $idToken = new IdToken($privateKey, $publicKey, 'https://example.com'); - $token = $idToken->issue('user-123', 'client-abc', 1000, 3600); - - $parts = explode('.', $token); - $result = openssl_verify( - $parts[0] . '.' . $parts[1], - base64_decode(strtr($parts[2], '-_', '+/')), - $publicKey, - OPENSSL_ALGO_SHA256, - ); - $this->assertSame(1, $result); - } - - /** - * Mirror of IdToken::leftHalfHash for assertion purposes. - */ - private function expectedLeftHalfHash(string $value): string - { - return rtrim(strtr(base64_encode(substr(hash('sha256', $value, true), 0, 16)), '+/', '-_'), '='); - } -} diff --git a/packages/auth/tests/Auth/Issuers/Symmetric/JwtTest.php b/packages/auth/tests/Auth/Issuers/Symmetric/JwtTest.php deleted file mode 100644 index 571813549..000000000 --- a/packages/auth/tests/Auth/Issuers/Symmetric/JwtTest.php +++ /dev/null @@ -1,71 +0,0 @@ - $audience */ - #[TestWith(['preview'])] - #[TestWith([['preview', 'other']])] - public function testRoundTripPreservesCustomClaimsAndProtectsRegisteredClaims(string|array $audience): void - { - $secret = Jwt::generateSecret(); - $issuer = new Jwt($secret, 'https://example.com'); - $before = time(); - $token = $issuer->issue($audience, 600, [ - 'purpose' => 'state', - 'iss' => 'https://wrong.example.com', - 'aud' => 'wrong', - 'iat' => 0, - 'exp' => 0, - ]); - $after = time(); - - $claims = (new Symmetric($secret, issuer: 'https://example.com', audience: 'preview', type: 'JWT')) - ->verify($token); - - $this->assertSame('https://example.com', $claims['iss']); - $this->assertSame($audience, $claims['aud']); - $this->assertSame('state', $claims['purpose']); - $this->assertIsInt($claims['iat']); - $this->assertGreaterThanOrEqual($before, $claims['iat']); - $this->assertLessThanOrEqual($after, $claims['iat']); - $this->assertSame($claims['iat'] + 600, $claims['exp']); - } - - /** @param string|array $audience */ - #[TestWith([''])] - #[TestWith([[]])] - #[TestWith([['']])] - #[TestWith([['preview', '']])] - #[TestWith([['recipient' => 'preview']])] - #[TestWith([[1 => 'preview']])] - #[TestWith([['preview', 123]])] - #[TestWith([[null]])] - #[TestWith([[false]])] - #[TestWith([[['preview']]])] - public function testRejectsInvalidAudience(string|array $audience): void - { - $issuer = new Jwt(Jwt::generateSecret(), 'https://example.com'); - - $this->expectException(\InvalidArgumentException::class); - $issuer->issue($audience, 600); - } - - #[TestWith([0])] - #[TestWith([-1])] - public function testRejectsNonPositiveDuration(int $duration): void - { - $issuer = new Jwt(Jwt::generateSecret(), 'https://example.com'); - - $this->expectException(\InvalidArgumentException::class); - $issuer->issue('preview', $duration); - } -} diff --git a/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php b/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php deleted file mode 100644 index a0416300b..000000000 --- a/packages/auth/tests/Auth/Issuers/Symmetric/RefreshTokenTest.php +++ /dev/null @@ -1,181 +0,0 @@ -secret = RefreshToken::generateSecret(); - - $this->refreshToken = new RefreshToken( - $this->secret, - 'https://example.com/v1/oauth2/test', - ); - } - - /** - * @return array - */ - private function decodeSegment(string $segment): array - { - /** @var array $claims */ - $claims = json_decode(base64_decode(strtr($segment, '-_', '+/')), true); - - return $claims; - } - - private function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } - - public function testHeaderUsesHs256AndNoKidByDefault(): void - { - $token = $this->refreshToken->issue('user-123', 'https://example.com/token', 'client-abc', 1209600); - $header = $this->decodeSegment(explode('.', $token)[0]); - - $this->assertEquals('JWT', $header['typ']); - $this->assertEquals('HS256', $header['alg']); - $this->assertArrayNotHasKey('kid', $header); - } - - public function testClaims(): void - { - $before = time(); - $token = $this->refreshToken->issue('user-123', 'https://example.com/token', 'client-abc', 1209600, ['read', 'offline_access']); - $after = time(); - - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - $this->assertEquals('https://example.com/token', $claims['aud']); - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('client-abc', $claims['client_id']); - $this->assertEquals('read offline_access', $claims['scope']); - $this->assertGreaterThanOrEqual($before, $claims['iat']); - $this->assertLessThanOrEqual($after, $claims['iat']); - $this->assertEquals($claims['iat'] + 1209600, $claims['exp']); - $jti = $claims['jti']; - \assert(\is_string($jti)); - $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $jti); - - // Refresh tokens carry no auth_time. - $this->assertArrayNotHasKey('auth_time', $claims); - } - - public function testSignatureIsValidHmac(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); - - $parts = explode('.', $token); - $expected = $this->base64UrlEncode(hash_hmac('sha256', $parts[0] . '.' . $parts[1], $this->secret, true)); - - $this->assertSame($expected, $parts[2]); - } - - public function testSignatureFailsWithWrongSecret(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); - - $parts = explode('.', $token); - $wrong = $this->base64UrlEncode(hash_hmac('sha256', $parts[0] . '.' . $parts[1], 'not-the-secret', true)); - - $this->assertNotSame($wrong, $parts[2]); - } - - public function testScopeOmittedWhenEmpty(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertArrayNotHasKey('scope', $claims); - } - - public function testScopeCannotBeInjectedViaClaimsWhenEmpty(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600, [], null, [ - 'scope' => 'admin', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertArrayNotHasKey('scope', $claims); - } - - public function testScopeCannotBeOverriddenViaClaims(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client-abc', 1209600, ['read'], null, [ - 'scope' => 'admin', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('read', $claims['scope']); - } - - public function testJtiIsGeneratedAndUnique(): void - { - $first = $this->decodeSegment(explode('.', $this->refreshToken->issue('u', 'a', 'c', 100))[1]); - $second = $this->decodeSegment(explode('.', $this->refreshToken->issue('u', 'a', 'c', 100))[1]); - - $this->assertNotEquals($first['jti'], $second['jti']); - } - - public function testCustomJti(): void - { - $token = $this->refreshToken->issue('u', 'a', 'c', 100, [], 'fixed-jti'); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('fixed-jti', $claims['jti']); - } - - public function testKidHeaderWhenConfigured(): void - { - $refreshToken = new RefreshToken($this->secret, 'https://example.com/v1/oauth2/test', 'secret-v2'); - - $this->assertSame('secret-v2', $refreshToken->getKeyId()); - - $header = $this->decodeSegment(explode('.', $refreshToken->issue('u', 'a', 'c', 100))[0]); - $this->assertEquals('secret-v2', $header['kid']); - } - - public function testAdditionalClaimsCannotOverrideRegisteredClaims(): void - { - $token = $this->refreshToken->issue('user-123', 'aud', 'client', 100, [], null, [ - 'sub' => 'attacker', - 'iss' => 'https://evil.example.com', - ]); - $claims = $this->decodeSegment(explode('.', $token)[1]); - - $this->assertEquals('user-123', $claims['sub']); - $this->assertEquals('https://example.com/v1/oauth2/test', $claims['iss']); - } - - public function testGenerateSecret(): void - { - $secret = RefreshToken::generateSecret(); - - $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $secret); - $this->assertNotSame($secret, RefreshToken::generateSecret()); - } - - public function testEmptySecretThrows(): void - { - $this->expectException(\Exception::class); - new RefreshToken('', 'https://example.com/v1/oauth2/test'); - } - - public function testEmptyIssuerThrows(): void - { - $this->expectException(\Exception::class); - new RefreshToken($this->secret, ''); - } -} diff --git a/packages/auth/tests/Auth/OAuth2/AuthorizationDetailsTest.php b/packages/auth/tests/Auth/OAuth2/AuthorizationDetailsTest.php deleted file mode 100644 index fe133cfbb..000000000 --- a/packages/auth/tests/Auth/OAuth2/AuthorizationDetailsTest.php +++ /dev/null @@ -1,147 +0,0 @@ - - */ - public static function grants(): iterable - { - $project = [['type' => 'project', 'identifiers' => ['p1'], 'actions' => ['read']]]; - - yield 'value listed in field' => [$project, 'project', 'p1', 'identifiers', null, true]; - yield 'value absent from field' => [$project, 'project', 'p2', 'identifiers', null, false]; - yield 'type must match' => [$project, 'organization', 'p1', 'identifiers', null, false]; - yield 'field must match' => [$project, 'project', 'p1', 'actions', null, false]; - yield 'other field matches' => [$project, 'project', 'read', 'actions', null, true]; - - $wildcard = [['type' => 'project', 'identifiers' => ['*']]]; - yield 'wildcard matches any value when given' => [$wildcard, 'project', 'anything', 'identifiers', '*', true]; - yield 'wildcard ignored when not given' => [$wildcard, 'project', 'anything', 'identifiers', null, false]; - - yield 'empty type' => [$project, '', 'p1', 'identifiers', null, false]; - yield 'empty value' => [$project, 'project', '', 'identifiers', null, false]; - yield 'empty field' => [$project, 'project', 'p1', '', null, false]; - - yield 'null input' => [null, 'project', 'p1', 'identifiers', null, false]; - yield 'scalar input' => ['nonsense', 'project', 'p1', 'identifiers', null, false]; - - // A JSON object decodes to an associative PHP array; it is not a list of - // entries, and a field that is a map is not a list of values. Neither - // may grant, at either level. - yield 'associative entry collection ignored' => [['named' => ['type' => 'project', 'identifiers' => ['p1']]], 'project', 'p1', 'identifiers', null, false]; - yield 'associative field ignored' => [[['type' => 'project', 'identifiers' => ['alias' => 'p1']]], 'project', 'p1', 'identifiers', null, false]; - - yield 'malformed entries ignored, valid entry honored' => [ - ['scalar', ['type' => 'project', 'identifiers' => 'not-a-list'], ['type' => 'project'], ['type' => 'project', 'identifiers' => ['p1']]], - 'project', 'p1', 'identifiers', null, true, - ]; - - // A numeric value in the field must not match the string lookup. - yield 'match is type strict' => [[['type' => 'project', 'identifiers' => [1]]], 'project', '1', 'identifiers', null, false]; - } - - #[DataProvider('grants')] - public function testGrants(mixed $raw, string $type, string $value, string $field, ?string $wildcard, bool $expected): void - { - $this->assertSame($expected, (new AuthorizationDetails($raw))->grants($type, $value, $field, $wildcard)); - } - - /** - * @return iterable>}> - */ - public static function restricts(): iterable - { - yield 'values narrowed, other fields kept' => [ - [['type' => 'project', 'identifiers' => ['p1', 'p2'], 'actions' => ['read']]], - [['type' => 'project', 'identifiers' => ['p1'], 'actions' => ['read']]], - ]; - yield 'entry dropped when nothing is allowed' => [ - [['type' => 'project', 'identifiers' => ['p2']]], - [], - ]; - yield 'ungoverned type passes through, order preserved' => [ - [ - ['type' => 'organization', 'identifiers' => ['o1', 'o2']], - ['type' => 'payment', 'actions' => ['initiate']], - ['type' => 'project', 'identifiers' => ['p1', 'p2']], - ], - [ - ['type' => 'organization', 'identifiers' => ['o1']], - ['type' => 'payment', 'actions' => ['initiate']], - ['type' => 'project', 'identifiers' => ['p1']], - ], - ]; - yield 'missing or malformed field resolves as no values' => [ - [['type' => 'project'], ['type' => 'project', 'identifiers' => ['alias' => 'p1']]], - [], - ]; - yield 'non-string values are neither offered nor returned' => [ - [['type' => 'project', 'identifiers' => [1, 'p1']]], - [['type' => 'project', 'identifiers' => ['p1']]], - ]; - } - - /** - * @param list> $expected - */ - #[DataProvider('restricts')] - public function testRestrict(mixed $raw, array $expected): void - { - $resolver = fn(string $type, array $values): ?array => match ($type) { - 'project' => array_intersect($values, ['p1', 1]), - 'organization' => array_intersect($values, ['o1']), - default => null, - }; - - $this->assertSame($expected, (new AuthorizationDetails($raw))->restrict('identifiers', $resolver)->toArray()); - } - - public function testRestrictDoesNotModifyTheReceiver(): void - { - $details = new AuthorizationDetails([['type' => 'project', 'identifiers' => ['p1', 'p2']]]); - - $restricted = $details->restrict('identifiers', fn(): array => ['p1']); - - $this->assertTrue($details->grants('project', 'p2', 'identifiers')); - $this->assertFalse($restricted->grants('project', 'p2', 'identifiers')); - } - - public function testRestrictCannotWidenTheGrant(): void - { - $details = new AuthorizationDetails([['type' => 'project', 'identifiers' => ['p2', 'p1']]]); - - $restricted = $details->restrict('identifiers', fn(): array => ['p1', 'p3', 'p2']); - - $this->assertSame([['type' => 'project', 'identifiers' => ['p2', 'p1']]], $restricted->toArray()); - $this->assertFalse($restricted->grants('project', 'p3', 'identifiers')); - } - - public function testRestrictExpandsAWildcardFromTheResolver(): void - { - $details = new AuthorizationDetails([ - ['type' => 'project', 'identifiers' => ['*']], - ['type' => 'organization', 'identifiers' => ['o1']], - ]); - - $restricted = $details->restrict('identifiers', fn(): array => ['x1', 'x2'], '*'); - - // The wildcard entry takes the resolver's expansion; the explicit entry still cannot widen. - $this->assertSame([['type' => 'project', 'identifiers' => ['x1', 'x2']]], $restricted->toArray()); - } - - public function testRestrictDropsEntryWhenResolverReturnsOnlyUngrantedValues(): void - { - $details = new AuthorizationDetails([['type' => 'project', 'identifiers' => ['p1']]]); - - $this->assertSame([], $details->restrict('identifiers', fn(): array => ['p2'])->toArray()); - } -} diff --git a/packages/auth/tests/Auth/OAuth2/ClientIdMetadataDocumentTest.php b/packages/auth/tests/Auth/OAuth2/ClientIdMetadataDocumentTest.php deleted file mode 100644 index 549732aee..000000000 --- a/packages/auth/tests/Auth/OAuth2/ClientIdMetadataDocumentTest.php +++ /dev/null @@ -1,144 +0,0 @@ - self::CLIENT_ID, - 'client_name' => 'Example MCP Client', - 'redirect_uris' => ['https://client.example/callback', 'myapp:/callback'], - 'grant_types' => ['authorization_code', 'refresh_token'], - 'response_types' => ['code'], - 'token_endpoint_auth_method' => 'none', - 'extension_property' => ['enabled' => true], - 'nullable_extension' => null, - ]; - - $document = ClientIdMetadataDocument::fromJson( - $this->clientId(), - json_encode($metadata, \JSON_THROW_ON_ERROR), - ); - - $this->assertSame(self::CLIENT_ID, $document->clientId()->toString()); - $this->assertSame('none', $document->tokenEndpointAuthMethod()); - $this->assertSame(['authorization_code', 'refresh_token'], $document->grantTypes()); - $this->assertSame(['code'], $document->responseTypes()); - $this->assertSame($metadata['redirect_uris'], $document->redirectUris()->toArray()); - $this->assertSame(['enabled' => true], $document->get('extension_property')); - $this->assertNull($document->get('nullable_extension', 'fallback')); - $this->assertSame($metadata, $document->toArray()); - } - - public function testAppliesRegistrationDefaults(): void - { - $document = ClientIdMetadataDocument::fromArray($this->clientId(), [ - 'client_id' => self::CLIENT_ID, - 'token_endpoint_auth_method' => 'none', - ]); - - $this->assertSame(['authorization_code'], $document->grantTypes()); - $this->assertSame(['code'], $document->responseTypes()); - $this->assertSame([], $document->redirectUris()->toArray()); - $this->assertSame('fallback', $document->get('unknown', 'fallback')); - } - - public function testAcceptsPublicKeyAuthentication(): void - { - $document = ClientIdMetadataDocument::fromArray($this->clientId(), [ - 'client_id' => self::CLIENT_ID, - 'token_endpoint_auth_method' => 'private_key_jwt', - 'jwks' => [ - 'keys' => [[ - 'kty' => 'RSA', - 'n' => 'public-modulus', - 'e' => 'AQAB', - ]], - ], - ]); - - $this->assertSame('private_key_jwt', $document->tokenEndpointAuthMethod()); - } - - #[DataProvider('invalidJsonProvider')] - public function testRejectsInvalidJson(string $json): void - { - $this->expectException(InvalidClientMetadataException::class); - - ClientIdMetadataDocument::fromJson($this->clientId(), $json); - } - - /** - * @param array $metadata - */ - #[DataProvider('invalidMetadataProvider')] - public function testRejectsInvalidMetadata(array $metadata): void - { - $this->expectException(InvalidClientMetadataException::class); - - ClientIdMetadataDocument::fromArray($this->clientId(), $metadata); - } - - /** - * @return \Iterator - */ - public static function invalidJsonProvider(): \Iterator - { - yield 'malformed' => ['json' => '{']; - yield 'list root' => ['json' => '[]']; - yield 'scalar root' => ['json' => 'true']; - } - - /** - * @return \Iterator}> - */ - public static function invalidMetadataProvider(): \Iterator - { - $valid = [ - 'client_id' => self::CLIENT_ID, - 'token_endpoint_auth_method' => 'none', - ]; - - yield 'missing client id' => ['metadata' => ['token_endpoint_auth_method' => 'none']]; - yield 'mismatched client id' => ['metadata' => array_replace($valid, ['client_id' => 'https://other.example/metadata'])]; - yield 'missing auth method' => ['metadata' => ['client_id' => self::CLIENT_ID]]; - yield 'empty auth method' => ['metadata' => array_replace($valid, ['token_endpoint_auth_method' => ''])]; - yield 'client secret basic' => ['metadata' => array_replace($valid, ['token_endpoint_auth_method' => 'client_secret_basic'])]; - yield 'future client secret method' => ['metadata' => array_replace($valid, ['token_endpoint_auth_method' => 'client_secret_custom'])]; - yield 'client secret' => ['metadata' => $valid + ['client_secret' => 'secret']]; - yield 'client secret expiry' => ['metadata' => $valid + ['client_secret_expires_at' => 0]]; - yield 'grant types not a list' => ['metadata' => $valid + ['grant_types' => 'authorization_code']]; - yield 'empty grant type' => ['metadata' => $valid + ['grant_types' => ['']]]; - yield 'response types not strings' => ['metadata' => $valid + ['response_types' => [1]]]; - yield 'redirect URI with fragment' => ['metadata' => $valid + ['redirect_uris' => ['https://client.example/callback#fragment']]]; - yield 'relative redirect URI' => ['metadata' => $valid + ['redirect_uris' => ['/callback']]]; - yield 'contacts not a list' => ['metadata' => $valid + ['contacts' => 'owner@example.com']]; - yield 'client name not a string' => ['metadata' => $valid + ['client_name' => ['Example']]]; - yield 'client name null' => ['metadata' => $valid + ['client_name' => null]]; - yield 'jwks null' => ['metadata' => $valid + ['jwks' => null]]; - yield 'malformed jwks' => ['metadata' => $valid + ['jwks' => ['keys' => 'not-a-list']]]; - yield 'jwks and jwks uri' => ['metadata' => $valid + [ - 'jwks' => ['keys' => []], - 'jwks_uri' => 'https://client.example/jwks.json', - ]]; - yield 'symmetric JWK' => ['metadata' => $valid + ['jwks' => ['keys' => [['kty' => 'oct', 'k' => 'secret']]]]]; - yield 'private RSA JWK' => ['metadata' => $valid + ['jwks' => ['keys' => [['kty' => 'RSA', 'n' => 'n', 'e' => 'AQAB', 'd' => 'private']]]]]; - } - - private function clientId(): ClientIdentifierUrl - { - return ClientIdentifierUrl::fromString(self::CLIENT_ID); - } -} diff --git a/packages/auth/tests/Auth/OAuth2/ClientIdentifierUrlTest.php b/packages/auth/tests/Auth/OAuth2/ClientIdentifierUrlTest.php deleted file mode 100644 index 869be1942..000000000 --- a/packages/auth/tests/Auth/OAuth2/ClientIdentifierUrlTest.php +++ /dev/null @@ -1,80 +0,0 @@ -assertSame($value, $identifier->toString()); - $this->assertSame($host, $identifier->host()); - } - - #[DataProvider('invalidUrlProvider')] - public function testRejectsInvalidUrl(string $value, bool $allowHttp = false): void - { - $this->expectException(InvalidClientMetadataException::class); - - ClientIdentifierUrl::fromString($value, $allowHttp); - } - - public function testCandidateDetection(): void - { - $this->assertTrue(ClientIdentifierUrl::isCandidate('https://client.example/metadata')); - $this->assertTrue(ClientIdentifierUrl::isCandidate('HTTPS://client.example/metadata')); - $this->assertTrue(ClientIdentifierUrl::isCandidate('http://localhost/metadata')); - $this->assertFalse(ClientIdentifierUrl::isCandidate('client.example/metadata')); - $this->assertFalse(ClientIdentifierUrl::isCandidate('opaque-client-id')); - } - - /** - * @return \Iterator - */ - public static function validUrlProvider(): \Iterator - { - yield 'path' => [ - 'value' => 'https://client.example/metadata', - 'host' => 'client.example', - ]; - yield 'root path is allowed' => [ - 'value' => 'https://client.example/', - 'host' => 'client.example', - ]; - yield 'port and query' => [ - 'value' => 'https://client.example:8443/metadata?version=1', - 'host' => 'client.example', - ]; - yield 'development http' => [ - 'value' => 'http://localhost/metadata', - 'host' => 'localhost', - 'allowHttp' => true, - ]; - } - - /** - * @return \Iterator - */ - public static function invalidUrlProvider(): \Iterator - { - yield 'empty' => ['value' => '']; - yield 'opaque identifier' => ['value' => 'opaque-client-id']; - yield 'http by default' => ['value' => 'http://client.example/metadata']; - yield 'missing path' => ['value' => 'https://client.example']; - yield 'missing host' => ['value' => 'https:///metadata']; - yield 'userinfo' => ['value' => 'https://user:password@client.example/metadata']; - yield 'fragment' => ['value' => 'https://client.example/metadata#fragment']; - yield 'single dot segment' => ['value' => 'https://client.example/a/./metadata']; - yield 'double dot segment' => ['value' => 'https://client.example/a/../metadata']; - yield 'invalid characters' => ['value' => 'https://client.example/a path']; - } -} diff --git a/packages/auth/tests/Auth/OAuth2/PARTest.php b/packages/auth/tests/Auth/OAuth2/PARTest.php deleted file mode 100644 index a1fab2648..000000000 --- a/packages/auth/tests/Auth/OAuth2/PARTest.php +++ /dev/null @@ -1,88 +0,0 @@ -assertSame('grant123', $par->id()); - $this->assertSame('urn:appwrite:oauth2:request:grant123', $par->requestUri()); - - $parsed = PAR::fromRequestUri(self::PREFIX, $par->requestUri()); - - $this->assertSame('grant123', $parsed->id()); - $this->assertSame('urn:appwrite:oauth2:request:grant123', $parsed->requestUri()); - } - - /** - * @param non-empty-string $requestUri - */ - #[DataProvider('invalidRequestUriProvider')] - public function testRejectsInvalidRequestUri(string $prefix, string $requestUri, string $message): void - { - $this->assertSame('invalid_request', InvalidRequestUriException::ERROR_CODE); - - $this->expectException(InvalidRequestUriException::class); - $this->expectExceptionMessage($message); - - PAR::fromRequestUri($prefix, $requestUri); - } - - #[DataProvider('invalidRequestUriPartsProvider')] - public function testRejectsEmptyRequestUriParts(string $prefix, string $id): void - { - $this->expectException(InvalidRequestUriException::class); - $this->expectExceptionMessage('request_uri prefix and id must be non-empty strings.'); - - PAR::fromId($prefix, $id); - } - - /** - * @return \Iterator - */ - public static function invalidRequestUriProvider(): \Iterator - { - yield 'empty prefix' => [ - 'prefix' => '', - 'requestUri' => 'urn:appwrite:oauth2:request:grant123', - 'message' => 'Invalid request_uri.', - ]; - yield 'wrong prefix' => [ - 'prefix' => self::PREFIX, - 'requestUri' => 'urn:example:oauth2:request:grant123', - 'message' => 'Invalid request_uri.', - ]; - yield 'empty id' => [ - 'prefix' => self::PREFIX, - 'requestUri' => self::PREFIX, - 'message' => 'Invalid request_uri.', - ]; - } - - /** - * @return \Iterator - */ - public static function invalidRequestUriPartsProvider(): \Iterator - { - yield 'empty prefix' => [ - 'prefix' => '', - 'id' => 'grant123', - ]; - yield 'empty id' => [ - 'prefix' => self::PREFIX, - 'id' => '', - ]; - } -} diff --git a/packages/auth/tests/Auth/OAuth2/PromptsTest.php b/packages/auth/tests/Auth/OAuth2/PromptsTest.php deleted file mode 100644 index 55b929003..000000000 --- a/packages/auth/tests/Auth/OAuth2/PromptsTest.php +++ /dev/null @@ -1,64 +0,0 @@ -assertSame([], $prompts->toArray()); - $this->assertSame('', $prompts->toString()); - $this->assertFalse($prompts->contains(Prompt::Consent)); - } - - public function testParsesValidPromptValues(): void - { - $prompts = Prompts::fromString('login consent select_account consent'); - - $this->assertSame(['login', 'consent', 'select_account'], $prompts->toArray()); - $this->assertSame('login consent select_account', $prompts->toString()); - $this->assertTrue($prompts->contains(Prompt::Login)); - $this->assertTrue($prompts->contains(Prompt::Consent)); - $this->assertTrue($prompts->contains(Prompt::SelectAccount)); - $this->assertFalse($prompts->contains(Prompt::None)); - } - - /** - * @param non-empty-string $prompt - */ - #[DataProvider('invalidPromptProvider')] - public function testRejectsInvalidPromptValues(string $prompt, string $message): void - { - $this->assertSame('invalid_request', InvalidPromptException::ERROR_CODE); - - $this->expectException(InvalidPromptException::class); - $this->expectExceptionMessage($message); - - Prompts::fromString($prompt); - } - - /** - * @return \Iterator - */ - public static function invalidPromptProvider(): \Iterator - { - yield 'unknown value' => [ - 'prompt' => 'login unknown', - 'message' => "Invalid prompt value 'unknown'.", - ]; - yield 'none combined' => [ - 'prompt' => 'none consent', - 'message' => 'prompt=none cannot be combined with other prompt values.', - ]; - } -} diff --git a/packages/auth/tests/Auth/OAuth2/RedirectUrisTest.php b/packages/auth/tests/Auth/OAuth2/RedirectUrisTest.php deleted file mode 100644 index 1cfc7bf9f..000000000 --- a/packages/auth/tests/Auth/OAuth2/RedirectUrisTest.php +++ /dev/null @@ -1,238 +0,0 @@ - $registered - */ - #[DataProvider('matchingProvider')] - public function testMatches(array $registered, string $presented, bool $expected, bool $allowLoopback = false): void - { - $this->assertSame($expected, RedirectUris::from($registered)->matches($presented, $allowLoopback)); - } - - public function testFromFiltersMalformedEntries(): void - { - $uris = RedirectUris::from(['https://example.com/cb', '', 42, null, ['nested']]); - - $this->assertSame(['https://example.com/cb'], $uris->toArray()); - $this->assertTrue($uris->matches('https://example.com/cb')); - } - - /** - * @return \Iterator, presented: string, expected: bool, allowLoopback?: bool}> - */ - public static function matchingProvider(): \Iterator - { - // Exact matching. - yield 'exact match' => [ - 'registered' => ['https://example.com/cb'], - 'presented' => 'https://example.com/cb', - 'expected' => true, - ]; - yield 'exact miss' => [ - 'registered' => ['https://example.com/cb'], - 'presented' => 'https://example.com/other', - 'expected' => false, - ]; - yield 'empty presented' => [ - 'registered' => ['https://example.com/cb'], - 'presented' => '', - 'expected' => false, - ]; - yield 'empty registered list' => [ - 'registered' => [], - 'presented' => 'https://example.com/cb', - 'expected' => false, - ]; - - // The RFC 8252 carve-out is opt-in: off by default (confidential clients). - yield 'loopback different port without opt-in' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => false, - ]; - yield 'loopback IPv4 different port without opt-in' => [ - 'registered' => ['http://127.0.0.1:3118/callback'], - 'presented' => 'http://127.0.0.1:54155/callback', - 'expected' => false, - ]; - yield 'loopback IPv6 different port without opt-in' => [ - 'registered' => ['http://[::1]:3118/callback'], - 'presented' => 'http://[::1]:54155/callback', - 'expected' => false, - ]; - yield 'loopback portless registered, ported presented without opt-in' => [ - 'registered' => ['http://localhost/callback'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => false, - ]; - yield 'loopback host case difference without opt-in' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://LOCALHOST:3118/callback', - 'expected' => false, - ]; - yield 'loopback empty path normalization without opt-in' => [ - 'registered' => ['http://localhost:3118'], - 'presented' => 'http://localhost:3118/', - 'expected' => false, - ]; - - // Exact matches never depend on the opt-in, loopback or not. - yield 'exact loopback match without opt-in' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:3118/callback', - 'expected' => true, - ]; - - // RFC 8252 loopback port variance. - yield 'loopback different port' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback portless registered, ported presented' => [ - 'registered' => ['http://localhost/callback'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback ported registered, portless presented' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback IPv4 different port' => [ - 'registered' => ['http://127.0.0.1:3118/callback'], - 'presented' => 'http://127.0.0.1:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback IPv6 different port' => [ - 'registered' => ['http://[::1]:3118/callback'], - 'presented' => 'http://[::1]:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback with matching query' => [ - 'registered' => ['http://localhost:3118/callback?flow=cli'], - 'presented' => 'http://localhost:54155/callback?flow=cli', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback empty path normalizes to root' => [ - 'registered' => ['http://localhost:3118'], - 'presented' => 'http://localhost:54155/', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback host is case-insensitive' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://LOCALHOST:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - yield 'loopback match among multiple registered' => [ - 'registered' => ['https://example.com/cb', 'http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => true, - 'allowLoopback' => true, - ]; - - // Host strictness. - yield 'localhost does not match 127.0.0.1' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://127.0.0.1:54155/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback lookalike host' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost.evil.com:3118/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - - // Scheme strictness. - yield 'https loopback stays exact-only' => [ - 'registered' => ['https://localhost:3118/callback'], - 'presented' => 'https://localhost:54155/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'custom scheme stays exact-only' => [ - 'registered' => ['myapp://localhost:3118/callback'], - 'presented' => 'myapp://localhost:54155/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - - // Component strictness. - yield 'loopback different path' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/other', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback path is case-sensitive' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/Callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback different query' => [ - 'registered' => ['http://localhost:3118/callback?flow=cli'], - 'presented' => 'http://localhost:54155/callback?flow=web', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback missing registered query' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/callback?flow=cli', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback presented fragment stays exact-only' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://localhost:54155/callback#fragment', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback registered fragment stays exact-only' => [ - 'registered' => ['http://localhost:3118/callback#fragment'], - 'presented' => 'http://localhost:54155/callback#fragment', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'loopback userinfo stays exact-only' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://user@localhost:54155/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - - // Robustness. - yield 'malformed presented URI' => [ - 'registered' => ['http://localhost:3118/callback'], - 'presented' => 'http://', - 'expected' => false, - 'allowLoopback' => true, - ]; - yield 'malformed registered URI never matches loopback' => [ - 'registered' => ['http://'], - 'presented' => 'http://localhost:54155/callback', - 'expected' => false, - 'allowLoopback' => true, - ]; - } -} diff --git a/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php b/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php deleted file mode 100644 index e1c9b285e..000000000 --- a/packages/auth/tests/Auth/OAuth2/ResourceIndicatorsTest.php +++ /dev/null @@ -1,134 +0,0 @@ -assertSame([], ResourceIndicators::from(null)->toArray()); - $this->assertSame([], ResourceIndicators::from('')->toArray()); - $this->assertSame(['https://api.example.com/'], ResourceIndicators::from('https://api.example.com/')->toArray()); - $this->assertSame( - ['https://api.example.com/'], - ResourceIndicators::from(null, 'https://api.example.com/')->toArray(), - ); - - $this->assertSame( - ['https://api.example.com/', 'http://localhost:8080/v1'], - ResourceIndicators::from([ - 'https://api.example.com/', - 'http://localhost:8080/v1', - 'https://api.example.com/', - ])->toArray(), - ); - $this->assertSame( - ['https://api.example.com/', 'http://localhost:8080/v1'], - ResourceIndicators::from([ - 'https://api.example.com/', - 'http://localhost:8080/v1', - ], 'https://api.example.com/')->toArray(), - ); - } - - /** - * @param string|array $resources - */ - #[DataProvider('invalidResourceProvider')] - public function testRejectsInvalidResources(string|array $resources, string $message, ?string $audience = null): void - { - $this->assertSame('invalid_target', InvalidResourceException::ERROR_CODE); - - $this->expectException(InvalidResourceException::class); - $this->expectExceptionMessage($message); - - ResourceIndicators::from($resources, $audience); - } - - public function testComparesResourceSets(): void - { - $this->assertTrue( - ResourceIndicators::from(['https://api.example.com/']) - ->isSubsetOf(ResourceIndicators::from(['https://api.example.com/', 'https://files.example.com/'])), - ); - $this->assertFalse( - ResourceIndicators::from(['https://api.example.com/']) - ->isSubsetOf(ResourceIndicators::from(['https://files.example.com/'])), - ); - - $this->assertTrue( - ResourceIndicators::from(['https://api.example.com/', 'https://files.example.com/']) - ->equals(ResourceIndicators::from(['https://files.example.com/', 'https://api.example.com/'])), - ); - } - - public function testBuildsAudience(): void - { - $this->assertSame( - ['https://cloud.appwrite.io/v1/project1'], - ResourceIndicators::from(null)->audience('https://cloud.appwrite.io/v1/project1'), - ); - - $this->assertSame( - ['https://mcp.example.com/'], - ResourceIndicators::from([ - 'https://mcp.example.com/', - ])->audience('https://cloud.appwrite.io/v1/project1'), - ); - - $this->assertSame( - ['https://cloud.appwrite.io/v1/project1'], - ResourceIndicators::from([ - 'https://cloud.appwrite.io/v1/project1', - ])->audience('https://cloud.appwrite.io/v1/project1'), - ); - } - - /** - * @return \Iterator | string), message: string}> - */ - public static function invalidResourceProvider(): \Iterator - { - 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.', - ]; - yield 'invalid audience' => [ - 'resources' => [], - 'message' => 'resource must be an absolute HTTP(S) URI with no fragment component.', - 'audience' => 'not-a-uri', - ]; - yield 'audience outside resources' => [ - 'resources' => ['https://api.example.com/'], - 'message' => 'audience must match one of the resource values when both parameters are provided.', - 'audience' => 'https://files.example.com/', - ]; - } -} diff --git a/packages/auth/tests/Auth/Proofs/CodeTest.php b/packages/auth/tests/Auth/Proofs/CodeTest.php deleted file mode 100644 index 738241646..000000000 --- a/packages/auth/tests/Auth/Proofs/CodeTest.php +++ /dev/null @@ -1,78 +0,0 @@ -code = new Code(); // Using default length of 6 - } - - public function testGenerate(): void - { - $proof = $this->code->generate(); - - $this->assertNotEmpty($proof); - $this->assertSame(6, \strlen($proof)); // Default code length - $this->assertMatchesRegularExpression('/^\d{6}$/', $proof); - } - - public function testHash(): void - { - $proof = $this->code->generate(); - $hash = $this->code->hash($proof); - - $this->assertNotEmpty($hash); - } - - public function testVerify(): void - { - $proof = $this->code->generate(); - $hash = $this->code->hash($proof); - - $this->assertTrue($this->code->verify($proof, $hash)); - $this->assertFalse($this->code->verify('000000', $hash)); - } - - public function testCustomLength(): void - { - $code = new Code(8); - $proof = $code->generate(); - - $this->assertSame(8, \strlen($proof)); - $this->assertMatchesRegularExpression('/^\d{8}$/', $proof); - } - - public function testGetLength(): void - { - $this->assertSame(6, $this->code->getLength()); - - $code = new Code(8); - $this->assertSame(8, $code->getLength()); - } - - public function testSetLength(): void - { - $this->code->setLength(4); - $this->assertSame(4, $this->code->getLength()); - - $proof = $this->code->generate(); - $this->assertSame(4, \strlen($proof)); - $this->assertMatchesRegularExpression('/^\d{4}$/', $proof); - } - - public function testSetLengthInvalid(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Code length must be greater than 0'); - $this->code->setLength(0); - } -} diff --git a/packages/auth/tests/Auth/Proofs/PasswordTest.php b/packages/auth/tests/Auth/Proofs/PasswordTest.php deleted file mode 100644 index 94d69efe6..000000000 --- a/packages/auth/tests/Auth/Proofs/PasswordTest.php +++ /dev/null @@ -1,219 +0,0 @@ -password = new Password(); - - // Test legacy constructor with explicit hashes - new Bcrypt(); - } - - public function testGenerate(): void - { - $proof = $this->password->generate(); - - $this->assertNotEmpty($proof); - $this->assertSame(16, \strlen($proof)); // Default length - $this->assertMatchesRegularExpression('/^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{}|;:,.<>?]+$/', $proof); - } - - public function testGenerateWithCustomLength(): void - { - $this->password->setLength(20); - $proof = $this->password->generate(); - $this->assertSame(20, \strlen($proof)); - } - - public function testGenerateWithCustomCharset(): void - { - $this->password->setCharset('abcdef123456'); - $proof = $this->password->generate(); - $this->assertMatchesRegularExpression('/^[abcdef123456]+$/', $proof); - } - - public function testSetLengthValidation(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Password length must be at least 8 characters'); - $this->password->setLength(7); - } - - public function testSetCharsetValidation(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Password charset must contain at least 10 characters'); - $this->password->setCharset('123456789'); - } - - public function testHash(): void - { - $proof = $this->password->generate(); - $hash = $this->password->hash($proof); - - $this->assertNotEmpty($hash); - $this->assertStringStartsWith('$argon2id$', $hash); // Default is now argon2 - } - - public function testVerify(): void - { - $proof = $this->password->generate(); - $hash = $this->password->hash($proof); - - $this->assertTrue($this->password->verify($proof, $hash)); - $this->assertFalse($this->password->verify('wrongpassword', $hash)); - } - - public function testAddHash(): void - { - $newBcrypt = new Bcrypt(); - $newBcrypt->setCost(8); - $this->password->addHash('bcrypt-8', $newBcrypt); - - // Verify the hash was added - $hash = $this->password->getHashByName('bcrypt-8'); - $this->assertInstanceOf(Bcrypt::class, $hash); - - // Test that the hash works - $proof = $this->password->generate(); - $this->password->setHash($hash); - $hash = $this->password->hash($proof); - - $this->assertTrue($this->password->verify($proof, $hash)); - $this->assertFalse($this->password->verify('wrongpassword', $hash)); - } - - public function testDefaultHashes(): void - { - // Test that all default hashes are initialized - $this->assertInstanceOf(Argon2::class, $this->password->getHashByName(Password::ARGON2)); - $this->assertInstanceOf(Bcrypt::class, $this->password->getHashByName(Password::BCRYPT)); - $this->assertInstanceOf(Scrypt::class, $this->password->getHashByName(Password::SCRYPT)); - $this->assertInstanceOf(ScryptModified::class, $this->password->getHashByName(Password::SCRYPT_MODIFIED)); - $this->assertInstanceOf(Sha::class, $this->password->getHashByName(Password::SHA)); - $this->assertInstanceOf(MD5::class, $this->password->getHashByName(Password::MD5)); - $this->assertInstanceOf(PHPass::class, $this->password->getHashByName(Password::PHPASS)); - } - - public function testRemoveHash(): void - { - // First try to remove the current hash (should fail) - $this->expectException(\Exception::class); - $this->password->removeHash('random-hash'); // Argon2 is the default current hash - } - - public function testRemoveNonCurrentHash(): void - { - // Should be able to remove a non-current hash - $this->password->removeHash(Password::MD5); - - // Verify it was removed - $this->expectException(\Exception::class); - $this->password->getHashByName(Password::MD5); - } - - public function testGetHash(): void - { - $hash = $this->password->getHashByName(Password::BCRYPT); - $this->assertInstanceOf(Bcrypt::class, $hash); - - $this->expectException(\Exception::class); - $this->password->getHashByName('non-existent-hash'); - } - - public function testAllHashesWork(): void - { - $proof = $this->password->generate(); - $hashes = [ - Password::ARGON2, - Password::BCRYPT, - Password::SCRYPT, - Password::SCRYPT_MODIFIED, - Password::SHA, - Password::MD5, - Password::PHPASS, - ]; - - foreach ($hashes as $algo) { - $hash = $this->password->getHashByName($algo); - $this->password->setHash($hash); - $hash = $this->password->hash($proof); - $this->assertTrue($this->password->verify($proof, $hash), "Hash {$algo} failed verification"); - $this->assertFalse($this->password->verify('wrongpassword', $hash), "Hash {$algo} failed wrong password test"); - } - } - - public function testCreateHash(): void - { - // Test default hash creation - $argon2Hash = Password::createHash(Password::ARGON2); - $this->assertInstanceOf(Argon2::class, $argon2Hash); - - $bcryptHash = Password::createHash(Password::BCRYPT); - $this->assertInstanceOf(Bcrypt::class, $bcryptHash); - - // Test hash creation with options - $customBcrypt = Password::createHash(Password::BCRYPT, [ - 'cost' => 8, - ]); - $this->assertInstanceOf(Bcrypt::class, $customBcrypt); - - $customScrypt = Password::createHash(Password::SCRYPT, [ - 'cpu_cost' => 8192, - 'memory_cost' => 4, - 'parallel_cost' => 1, - 'key_length' => 32, - ]); - $this->assertInstanceOf(Scrypt::class, $customScrypt); - - // Test invalid hash type - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Unsupported hash type: invalid-hash'); - Password::createHash('invalid-hash'); - } - - public function testCreateHashWithInvalidOptions(): void - { - // Test that invalid options are ignored - $hash = Password::createHash(Password::BCRYPT, [ - 'invalid_option' => 'value', - ]); - $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 deleted file mode 100644 index 861c91152..000000000 --- a/packages/auth/tests/Auth/Proofs/PhraseTest.php +++ /dev/null @@ -1,45 +0,0 @@ -phrase = new Phrase(); - } - - public function testGenerate(): void - { - $proof = $this->phrase->generate(); - - $this->assertNotEmpty($proof); - $this->assertStringContainsString(' ', $proof); // Should contain spaces between words - $this->assertMatchesRegularExpression('/^[a-zA-Z\s-]+$/', $proof); // Letters, spaces, and hyphens (e.g. "Quick-witted") - } - - public function testHash(): void - { - $proof = $this->phrase->generate(); - $hash = $this->phrase->hash($proof); - - $this->assertNotEmpty($hash); - $this->assertStringStartsWith('$argon2id$', $hash); - } - - public function testVerify(): void - { - $proof = $this->phrase->generate(); - $hash = $this->phrase->hash($proof); - - $this->assertTrue($this->phrase->verify($proof, $hash)); - $this->assertFalse($this->phrase->verify('wrong phrase here', $hash)); - } -} diff --git a/packages/auth/tests/Auth/Proofs/TokenTest.php b/packages/auth/tests/Auth/Proofs/TokenTest.php deleted file mode 100644 index e1e270ecd..000000000 --- a/packages/auth/tests/Auth/Proofs/TokenTest.php +++ /dev/null @@ -1,74 +0,0 @@ -token = new Token(32); - $this->token->setHash(new Sha()); - /** @var Sha */ - $hash = $this->token->getHash(); - $hash->setVersion('sha256'); - } - - public function testGenerate(): void - { - $proof = $this->token->generate(); - - $this->assertNotEmpty($proof); - $this->assertSame(32, \strlen($proof)); // Default token length - } - - public function testHash(): void - { - $proof = $this->token->generate(); - $hash = $this->token->hash($proof); - - $this->assertNotEmpty($hash); - $this->assertSame(64, \strlen($hash)); // SHA-256 produces a 64-character hex string - $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash); // SHA-256 hex format - } - - public function testVerify(): void - { - $proof = $this->token->generate(); - $hash = $this->token->hash($proof); - - $this->assertTrue($this->token->verify($proof, $hash)); - $this->assertFalse($this->token->verify('wrongtoken', $hash)); - } - - public function testGetLength(): void - { - $this->assertSame(32, $this->token->getLength()); - - $token = new Token(64); - $this->assertSame(64, $token->getLength()); - } - - public function testSetLength(): void - { - $this->token->setLength(64); - $this->assertSame(64, $this->token->getLength()); - - $proof = $this->token->generate(); - $this->assertSame(64, \strlen($proof)); - } - - public function testSetLengthInvalid(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Token length must be greater than 0'); - $this->token->setLength(0); - } -} diff --git a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php b/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php deleted file mode 100644 index b718f5074..000000000 --- a/packages/auth/tests/Auth/Verifiers/AsymmetricTest.php +++ /dev/null @@ -1,229 +0,0 @@ -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 deleted file mode 100644 index 4a3d42ac9..000000000 --- a/packages/auth/tests/Auth/Verifiers/SymmetricTest.php +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 5d282a5ba..000000000 --- a/packages/auth/tests/StoreTest.php +++ /dev/null @@ -1,119 +0,0 @@ -setProperty('name', 'John Doe'); - $this->assertEquals('John Doe', $store->getProperty('name')); - - // Test setting and getting different types - $store->setProperty('age', 30) - ->setProperty('active', true) - ->setProperty('scores', [95, 87, 92]) - ->setProperty('details', ['city' => 'New York', 'country' => 'USA']); - - $this->assertEquals(30, $store->getProperty('age')); - $this->assertTrue($store->getProperty('active')); - $this->assertEquals([95, 87, 92], $store->getProperty('scores')); - $this->assertEquals(['city' => 'New York', 'country' => 'USA'], $store->getProperty('details')); - - // Test default value for non-existent key - $this->assertNull($store->getProperty('nonexistent')); - $this->assertEquals('default', $store->getProperty('nonexistent', 'default')); - } - - public function testGetAndSetKey(): void - { - $store = new Store(); - - // Test initial key is null - $this->assertNull($store->getKey()); - - // Test setting and getting a key - $store->setKey('test-key'); - $this->assertEquals('test-key', $store->getKey()); - - // Test setting key to null - $store->setKey(null); - $this->assertNull($store->getKey()); - - // Test method chaining - $store->setKey('new-key')->setProperty('test', 'value'); - $this->assertEquals('new-key', $store->getKey()); - $this->assertEquals('value', $store->getProperty('test')); - } - - public function testEncodeAndDecode(): void - { - $store = new Store(); - $data = [ - 'name' => 'John Doe', - 'age' => 30, - 'active' => true, - 'scores' => [95, 87, 92], - 'details' => ['city' => 'New York', 'country' => 'USA'], - ]; - - // Set multiple values and key - foreach ($data as $key => $value) { - $store->setProperty($key, $value); - } - $store->setKey('test-key'); - - // Encode the store - $encoded = $store->encode(); - - // Verify it's a valid base64 string - $decoded = base64_decode($encoded, true); - $this->assertNotFalse($decoded); - $this->assertSame($encoded, base64_encode($decoded)); - - // Create a new store and decode the data - $newStore = new Store(); - $newStore->decode($encoded); - - // Verify all data was preserved - foreach ($data as $key => $value) { - $this->assertEquals($value, $store->getProperty($key)); - } - } - - public function testDecodeInvalidData(): void - { - $store = new Store(); - - // Test decoding invalid base64 - $store->decode('invalid-base64'); - $this->assertNull($store->getProperty('any')); - - // Test decoding valid base64 but invalid JSON - $store->decode(base64_encode('invalid-json')); - $this->assertNull($store->getProperty('any')); - - // Test decoding valid base64 and JSON, but not an array - $json = json_encode('string', JSON_THROW_ON_ERROR); - $store->decode(base64_encode($json)); - $this->assertNull($store->getProperty('any')); - } - - public function testEncodeWithInvalidData(): void - { - $store = new Store(); - // Create an invalid UTF-8 string that will cause json_encode to fail - $store->setProperty('invalid', "\xFF"); - - $this->expectException(\JsonException::class); - $store->encode(); - } -}