From 5fbc30360dc3d3cc5a7fee0806162544d4fd5f99 Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sat, 19 Sep 2026 23:21:56 +0600 Subject: [PATCH 1/2] Turn Accounts into the 3AG OpenID Connect provider A client signs in once at accounts.3ag.app and the product apps sign them in from there. This app owns identity and nothing else: every product keeps its own codebase, database, APP_KEY and host-only session cookie, and the only thing crossing the boundary is a signed ID token. Passport supplies the OAuth 2.1 server. It ships no OIDC, so app/Oidc adds the missing half: ID tokens signed RS256 with Passport's key pair, the discovery document, a JWKS endpoint, userinfo, and RP-initiated logout. Two behaviours are specific to how we work: - First-party clients skip the consent screen. Client::skipsAuthorization() returns the new first_party flag, so a signed-in user is sent straight back to one of our own products. The Blade consent page still stands for anything else. - Accounts decides which product a user may enter. Without a client_user grant the authorization endpoint refuses, so no code is ever issued. The products keep their own roles and permissions; this is the front door. The OIDC parameters that must outlive the authorization code ride on the oauth_auth_codes row: a custom AuthCodeRepository writes nonce and auth_time, and ScopeRepository reads them back in finalizeScopes(), the one point of the token exchange handed the code's identifier. The refresh grant arrives there with no code, so a refreshed ID token correctly carries no nonce. The subject claim is a ULID public_id rather than the primary key, so products store something opaque that survives a change of name or email. Fortify handles login, password reset, email verification and 2FA behind hand-written Blade views. There is no public registration: accounts:create-user makes an account and mails a link to set a password, and accounts:grant and accounts:revoke manage access per product. Revoking also kills the tokens already issued, refresh tokens included, since Passport never looks past a refresh token's own revoked flag. Production runs MySQL. deploy.php shares only storage and .env, so a SQLite file under database/ would be replaced on every release, and CI now runs the suite against MySQL for the same reason. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + .claude/skills/fortify-development/SKILL.md | 151 + .claude/skills/passport-development/SKILL.md | 197 + .env.example | 30 +- .github/workflows/tests.yml | 29 +- CLAUDE.md | 10 + README.md | 163 +- .../Fortify/PasswordValidationRules.php | 19 + app/Actions/Fortify/ResetUserPassword.php | 32 + app/Actions/Fortify/UpdateUserPassword.php | 35 + .../Fortify/UpdateUserProfileInformation.php | 61 + .../Concerns/ResolvesAccountsAndClients.php | 45 + app/Console/Commands/CreateUserCommand.php | 74 + .../Commands/GrantClientAccessCommand.php | 51 + .../Commands/RevokeClientAccessCommand.php | 69 + app/Http/Controllers/DashboardController.php | 19 + .../Controllers/Oidc/DiscoveryController.php | 38 + .../Controllers/Oidc/EndSessionController.php | 120 + app/Http/Controllers/Oidc/JwksController.php | 20 + .../Controllers/Oidc/UserInfoController.php | 31 + app/Http/Controllers/SettingsController.php | 19 + .../CaptureOidcAuthorizeParameters.php | 45 + .../Middleware/EnsureUserCanAccessClient.php | 81 + app/Models/Client.php | 79 + app/Models/User.php | 42 +- app/Oidc/AuthorizeContext.php | 28 + app/Oidc/AuthorizeSession.php | 89 + app/Oidc/IdTokenBuilder.php | 143 + app/Oidc/IdTokenResponse.php | 61 + app/Oidc/MemoizedAccessToken.php | 91 + app/Oidc/SigningKey.php | 127 + app/Passport/AuthCodeRepository.php | 40 + app/Passport/ScopeRepository.php | 52 + app/Providers/FortifyServiceProvider.php | 88 + app/Providers/PassportServiceProvider.php | 60 + boost.json | 2 + bootstrap/app.php | 17 +- bootstrap/providers.php | 4 + composer.json | 3 + composer.lock | 5067 ++++++++++++----- config/auth.php | 5 + config/fortify.php | 178 + config/passport.php | 48 + config/products.php | 50 + database/factories/ClientFactory.php | 44 + ..._add_two_factor_columns_to_users_table.php | 42 + ...026_09_19_105303_create_passkeys_table.php | 35 + ...9_105331_create_oauth_auth_codes_table.php | 39 + ...05332_create_oauth_access_tokens_table.php | 41 + ...5333_create_oauth_refresh_tokens_table.php | 37 + ...9_19_105334_create_oauth_clients_table.php | 42 + ...105335_create_oauth_device_codes_table.php | 42 + ...19_105347_add_public_id_to_users_table.php | 35 + ...dd_oidc_columns_to_oauth_clients_table.php | 37 + ...oidc_columns_to_oauth_auth_codes_table.php | 37 + ..._09_19_105350_create_client_user_table.php | 31 + database/seeders/ClientSeeder.php | 68 + database/seeders/DatabaseSeeder.php | 26 +- phpunit.xml | 1 + .../views/auth/confirm-password.blade.php | 12 + .../views/auth/forgot-password.blade.php | 20 + resources/views/auth/login.blade.php | 30 + resources/views/auth/reset-password.blade.php | 14 + .../views/auth/two-factor-challenge.blade.php | 24 + resources/views/auth/verify-email.blade.php | 24 + .../views/components/input-error.blade.php | 9 + .../views/components/layouts/app.blade.php | 35 + .../views/components/layouts/guest.blade.php | 25 + .../views/components/primary-button.blade.php | 3 + .../views/components/text-input.blade.php | 12 + resources/views/dashboard.blade.php | 31 + resources/views/oauth/access-denied.blade.php | 13 + resources/views/oauth/authorize.blade.php | 40 + resources/views/settings.blade.php | 69 + resources/views/welcome.blade.php | 223 - routes/web.php | 39 +- tests/Feature/AccountManagementTest.php | 97 + tests/Feature/AuthenticationPromptTest.php | 68 + tests/Feature/AuthorizationCodeFlowTest.php | 190 + tests/Feature/ClientAccessGateTest.php | 113 + tests/Feature/ConsentScreenTest.php | 110 + tests/Feature/EndSessionTest.php | 75 + tests/Feature/ExampleTest.php | 7 - tests/Feature/OpenIdDiscoveryTest.php | 40 + tests/Feature/RefreshTokenTest.php | 90 + tests/Feature/UserInfoTest.php | 59 + tests/Pest.php | 76 +- tests/Unit/ExampleTest.php | 5 - 88 files changed, 7920 insertions(+), 1814 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .claude/skills/fortify-development/SKILL.md create mode 100644 .claude/skills/passport-development/SKILL.md create mode 100644 app/Actions/Fortify/PasswordValidationRules.php create mode 100644 app/Actions/Fortify/ResetUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserPassword.php create mode 100644 app/Actions/Fortify/UpdateUserProfileInformation.php create mode 100644 app/Console/Commands/Concerns/ResolvesAccountsAndClients.php create mode 100644 app/Console/Commands/CreateUserCommand.php create mode 100644 app/Console/Commands/GrantClientAccessCommand.php create mode 100644 app/Console/Commands/RevokeClientAccessCommand.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Controllers/Oidc/DiscoveryController.php create mode 100644 app/Http/Controllers/Oidc/EndSessionController.php create mode 100644 app/Http/Controllers/Oidc/JwksController.php create mode 100644 app/Http/Controllers/Oidc/UserInfoController.php create mode 100644 app/Http/Controllers/SettingsController.php create mode 100644 app/Http/Middleware/CaptureOidcAuthorizeParameters.php create mode 100644 app/Http/Middleware/EnsureUserCanAccessClient.php create mode 100644 app/Models/Client.php create mode 100644 app/Oidc/AuthorizeContext.php create mode 100644 app/Oidc/AuthorizeSession.php create mode 100644 app/Oidc/IdTokenBuilder.php create mode 100644 app/Oidc/IdTokenResponse.php create mode 100644 app/Oidc/MemoizedAccessToken.php create mode 100644 app/Oidc/SigningKey.php create mode 100644 app/Passport/AuthCodeRepository.php create mode 100644 app/Passport/ScopeRepository.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 app/Providers/PassportServiceProvider.php create mode 100644 config/fortify.php create mode 100644 config/passport.php create mode 100644 config/products.php create mode 100644 database/factories/ClientFactory.php create mode 100644 database/migrations/2026_09_19_105302_add_two_factor_columns_to_users_table.php create mode 100644 database/migrations/2026_09_19_105303_create_passkeys_table.php create mode 100644 database/migrations/2026_09_19_105331_create_oauth_auth_codes_table.php create mode 100644 database/migrations/2026_09_19_105332_create_oauth_access_tokens_table.php create mode 100644 database/migrations/2026_09_19_105333_create_oauth_refresh_tokens_table.php create mode 100644 database/migrations/2026_09_19_105334_create_oauth_clients_table.php create mode 100644 database/migrations/2026_09_19_105335_create_oauth_device_codes_table.php create mode 100644 database/migrations/2026_09_19_105347_add_public_id_to_users_table.php create mode 100644 database/migrations/2026_09_19_105348_add_oidc_columns_to_oauth_clients_table.php create mode 100644 database/migrations/2026_09_19_105349_add_oidc_columns_to_oauth_auth_codes_table.php create mode 100644 database/migrations/2026_09_19_105350_create_client_user_table.php create mode 100644 database/seeders/ClientSeeder.php create mode 100644 resources/views/auth/confirm-password.blade.php create mode 100644 resources/views/auth/forgot-password.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 resources/views/auth/two-factor-challenge.blade.php create mode 100644 resources/views/auth/verify-email.blade.php create mode 100644 resources/views/components/input-error.blade.php create mode 100644 resources/views/components/layouts/app.blade.php create mode 100644 resources/views/components/layouts/guest.blade.php create mode 100644 resources/views/components/primary-button.blade.php create mode 100644 resources/views/components/text-input.blade.php create mode 100644 resources/views/dashboard.blade.php create mode 100644 resources/views/oauth/access-denied.blade.php create mode 100644 resources/views/oauth/authorize.blade.php create mode 100644 resources/views/settings.blade.php delete mode 100644 resources/views/welcome.blade.php create mode 100644 tests/Feature/AccountManagementTest.php create mode 100644 tests/Feature/AuthenticationPromptTest.php create mode 100644 tests/Feature/AuthorizationCodeFlowTest.php create mode 100644 tests/Feature/ClientAccessGateTest.php create mode 100644 tests/Feature/ConsentScreenTest.php create mode 100644 tests/Feature/EndSessionTest.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/OpenIdDiscoveryTest.php create mode 100644 tests/Feature/RefreshTokenTest.php create mode 100644 tests/Feature/UserInfoTest.php delete mode 100644 tests/Unit/ExampleTest.php diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..bdbade3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "accounts", + "runtimeExecutable": "php", + "runtimeArgs": ["artisan", "serve", "--port=8000"], + "port": 8000 + } + ] +} diff --git a/.claude/skills/fortify-development/SKILL.md b/.claude/skills/fortify-development/SKILL.md new file mode 100644 index 0000000..2c4e84f --- /dev/null +++ b/.claude/skills/fortify-development/SKILL.md @@ -0,0 +1,151 @@ +--- +name: fortify-development +description: 'ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), passkeys, profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, passkeys, WebAuthn, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.' +license: MIT +metadata: + author: laravel +--- + +# Laravel Fortify Development + +Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications. + +## Documentation + +Use `search-docs` for detailed Laravel Fortify patterns and documentation. + +## Usage + +- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints +- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.) +- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field +- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.) +- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc. + +## Available Features + +Enable in `config/fortify.php` features array: + +- `Features::registration()` - User registration +- `Features::resetPasswords()` - Password reset via email +- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail` +- `Features::updateProfileInformation()` - Profile updates +- `Features::updatePasswords()` - Password changes +- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes +- `Features::passkeys()` - Passwordless authentication with WebAuthn passkeys + +> Use `search-docs` for feature configuration options and customization patterns. + +## Setup Workflows + +### Two-Factor Authentication Setup + +``` +- [ ] Add TwoFactorAuthenticatable trait to User model +- [ ] Enable feature in config/fortify.php +- [ ] If the `*_add_two_factor_columns_to_users_table.php` migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate +- [ ] Set up view callbacks in FortifyServiceProvider +- [ ] Create 2FA management UI +- [ ] Test QR code and recovery codes +``` + +> Use `search-docs` for TOTP implementation and recovery code handling patterns. + +### Passkeys Setup + +``` +- [ ] Add PasskeyAuthenticatable trait to User model and implement PasskeyUser +- [ ] Enable passkeys feature in config/fortify.php +- [ ] If the passkeys table migration is missing, publish via `php artisan vendor:publish --tag=fortify-migrations` and migrate +- [ ] Configure passkeys relying_party_id, allowed_origins, user_handle_secret, and timeout if defaults are not suitable +- [ ] Build UI with @laravel/passkeys for registration, login, confirmation, and deletion +``` + +> Use `search-docs` for passkey configuration options. For `@laravel/passkeys` frontend usage, refer to the package's README on npm. + +### Email Verification Setup + +``` +- [ ] Enable emailVerification feature in config +- [ ] Implement MustVerifyEmail interface on User model +- [ ] Set up verifyEmailView callback +- [ ] Add verified middleware to protected routes +- [ ] Test verification email flow +``` + +> Use `search-docs` for MustVerifyEmail implementation patterns. + +### Password Reset Setup + +``` +- [ ] Enable resetPasswords feature in config +- [ ] Set up requestPasswordResetLinkView callback +- [ ] Set up resetPasswordView callback +- [ ] Define password.reset named route (if views disabled) +- [ ] Test reset email and link flow +``` + +> Use `search-docs` for custom password reset flow patterns. + +### SPA Authentication Setup + +``` +- [ ] Set 'views' => false in config/fortify.php +- [ ] Install and configure Laravel Sanctum for session-based SPA authentication +- [ ] Use the 'web' guard in config/fortify.php (required for session-based authentication) +- [ ] Set up CSRF token handling +- [ ] Test XHR authentication flows +``` + +> Use `search-docs` for integration and SPA authentication patterns. + +#### Two-Factor Authentication in SPA Mode + +When `views` is set to `false`, Fortify returns JSON responses instead of redirects. + +If a user attempts to log in and two-factor authentication is enabled, the login request will return a JSON response indicating that a two-factor challenge is required: + +```json +{ + "two_factor": true +} +``` + +## Best Practices + +### Custom Authentication Logic + +Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects. + +### Registration Customization + +Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields. + +### Rate Limiting + +Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination. + +## Key Endpoints + +| Feature | Method | Endpoint | +|------------------------|----------|---------------------------------------------| +| Login | POST | `/login` | +| Logout | POST | `/logout` | +| Register | POST | `/register` | +| Password Reset Request | POST | `/forgot-password` | +| Password Reset | POST | `/reset-password` | +| Email Verify Notice | GET | `/email/verify` | +| Resend Verification | POST | `/email/verification-notification` | +| Password Confirm | POST | `/user/confirm-password` | +| Enable 2FA | POST | `/user/two-factor-authentication` | +| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` | +| 2FA Challenge | POST | `/two-factor-challenge` | +| Get QR Code | GET | `/user/two-factor-qr-code` | +| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` | +| Passkey Login Options | GET | `/passkeys/login/options` | +| Passkey Login | POST | `/passkeys/login` | +| Passkey Confirm Options| GET | `/passkeys/confirm/options` | +| Passkey Confirm | POST | `/passkeys/confirm` | +| Passkey Options | GET | `/user/passkeys/options` | +| Register Passkey | POST | `/user/passkeys` | +| Delete Passkey | DELETE | `/user/passkeys/{passkey}` | diff --git a/.claude/skills/passport-development/SKILL.md b/.claude/skills/passport-development/SKILL.md new file mode 100644 index 0000000..6a083d9 --- /dev/null +++ b/.claude/skills/passport-development/SKILL.md @@ -0,0 +1,197 @@ +--- +name: passport-development +description: "Develops OAuth2 API authentication with Laravel Passport. Activates when installing or configuring Passport; setting up OAuth2 grants (authorization code, client credentials, personal access tokens, device authorization); managing OAuth clients; protecting API routes with token authentication; defining or checking token scopes; configuring SPA cookie authentication; handling token lifetimes and refresh tokens; or when the user mentions Passport, OAuth2, API tokens, bearer tokens, or API authentication. Make sure to use this skill whenever the user works with OAuth2, API tokens, or third-party API access, even if they don't explicitly mention Passport." +license: MIT +metadata: + author: laravel +--- + +# Passport OAuth2 Authentication + +## Documentation First + +**Always use `search-docs` before writing Passport code.** The documentation covers every grant type, configuration option, and edge case in detail. This skill teaches you how to navigate Passport — the docs have the implementation specifics. + +``` +search-docs(queries: ["Passport installation"], packages: ["laravel/framework@12.x"]) +``` + +The Passport docs live under the `laravel/framework` package — not `laravel/passport`. + +## When to Apply + +Activate this skill when: + +- Installing or configuring Passport +- Setting up OAuth2 authorization grants +- Creating or managing OAuth clients +- Protecting API routes with token authentication +- Defining or checking token scopes +- Configuring SPA cookie-based authentication +- Choosing between Passport and Sanctum + +## Passport vs. Sanctum + +**Passport** is a full OAuth2 server — use it when third-party applications need to consume your API and when you need OAuth2 authorization code grants, client credentials for machine-to-machine auth, or device authorization flow. + +**Sanctum** is simpler — use it when first-party SPAs, third parties, or mobile apps consume the API but you don't need the full OAuth2 grant flows. + +## Installation + +Three steps are always required: + +### 1. Install Passport + +```bash +php artisan install:api --passport +``` + +This publishes migrations, generates encryption keys, and registers routes. + +### 2. Configure the User model + +The User model needs both the `HasApiTokens` trait AND the `OAuthenticatable` interface. Missing the interface is the most common Passport setup mistake — it causes runtime errors that can be confusing to debug. + +```php +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; + +class User extends Authenticatable implements OAuthenticatable +{ + use HasApiTokens; +} +``` + +### 3. Configure the auth guard + +The `api` guard must use the `passport` driver in `config/auth.php`. Using `token` or `sanctum` here silently breaks Passport authentication. + +```php +'guards' => [ + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], +], +``` + +## Choosing a Grant Type + +Matching the right grant to the use case is the most important Passport decision. Use `search-docs` for implementation details of any grant. + +| Use Case | Grant Type | Client Flag | +|----------|-----------|-------------| +| Third-party app accessing user data | Authorization Code | (default) | +| Mobile/SPA without client secret | Authorization Code + PKCE | `--public` | +| Machine-to-machine, no user context | Client Credentials | `--client` | +| User-generated API keys | Personal Access Tokens | `--personal` | +| Smart TV, CLI, IoT devices | Device Authorization | `--device` | + +**Legacy grants** (Password, Implicit) are disabled by default and not recommended. They must be explicitly enabled with `Passport::enablePasswordGrant()` or `Passport::enableImplicitGrant()`. + +## Client Management + +Create clients with the appropriate flag for the grant type: + +```bash +php artisan passport:client # Authorization code +php artisan passport:client --public # PKCE (no secret) +php artisan passport:client --client # Client credentials +php artisan passport:client --personal # Personal access tokens +php artisan passport:client --device # Device authorization +``` + +Additional flags: `--name=`, `--redirect_uri=`, `--provider=`. + +Client secrets are hashed by default — the plain-text secret is only shown at creation time and cannot be retrieved later. + +## Protecting Routes + +Apply `auth:api` middleware. Clients send tokens via the `Authorization: Bearer ` header. + +```php +Route::get('/user', function (Request $request) { + return $request->user(); +})->middleware('auth:api'); +``` + +### Scope Enforcement + +Scope middleware must come alongside `auth:api`: + +- `CheckToken::using('scope1', 'scope2')` — requires ALL listed scopes +- `CheckTokenForAnyScope::using('scope1', 'scope2')` — requires ANY listed scope +- `EnsureClientIsResourceOwner::using('scope1')` — restricts to client credential tokens + +```php +use Laravel\Passport\Http\Middleware\CheckToken; + +Route::get('/orders', function () { + // ... +})->middleware(['auth:api', CheckToken::using('orders:read')]); +``` + +### Programmatic scope checking + +```php +if ($request->user()->tokenCan('place-orders')) { + // ... +} +``` + +Use `search-docs` for full scope middleware registration and usage patterns. + +## Key Configuration + +Configure in `AppServiceProvider::boot()`. Use `search-docs` for the full list of options. + +```php +// Token lifetimes (each is independent) +Passport::tokensExpireIn(now()->addDays(15)); +Passport::refreshTokensExpireIn(now()->addDays(30)); +Passport::personalAccessTokensExpireIn(now()->addMonths(6)); + +// Define scopes +Passport::tokensCan([ + 'place-orders' => 'Place orders', + 'check-status' => 'Check order status', +]); +``` + +## SPA Cookie Authentication + +For first-party SPAs, the `CreateFreshApiToken` middleware issues a `laravel_token` cookie containing an encrypted JWT. The SPA must include CSRF tokens — missing the `X-CSRF-TOKEN` or `X-XSRF-TOKEN` header causes 419 errors. + +Use `search-docs` for setup details — this feature has specific CSRF and cookie configuration requirements. + +## Testing + +Passport provides helpers to bypass full OAuth flows in tests: + +```php +Passport::actingAs($user, ['scope1', 'scope2']); +Passport::actingAsClient($client, ['scope1']); +``` + +## Token Maintenance + +```bash +php artisan passport:purge # Purge revoked & expired +php artisan passport:purge --revoked # Only revoked +php artisan passport:purge --expired # Only expired +``` + +Schedule `passport:purge` for regular expired token clean-up. + +## Events + +All in `Laravel\Passport\Events`: `AccessTokenCreated`, `AccessTokenRevoked`, `RefreshTokenCreated`. + +## Common Pitfalls + +- **Missing `OAuthenticatable` interface** — both the `HasApiTokens` trait and the `OAuthenticatable` interface are required on the User model. Missing the interface causes runtime errors. +- **Wrong guard driver** — the `api` guard must use `passport`, not `token` or `sanctum`. This fails silently. +- **Token lifetime confusion** — access token, refresh token, and personal access token lifetimes are all independent settings. +- **Missing CSRF for SPA cookie auth** — `CreateFreshApiToken` requires CSRF tokens. Use `Passport::ignoreCsrfToken()` only if you understand the security implications. +- **Client secrets are hashed** — the plain-text secret is only available at creation time. +- **Legacy grants are disabled** — Password and Implicit grants must be explicitly enabled and are not recommended. diff --git a/.env.example b/.env.example index 40bb13b..89d9e94 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Laravel +APP_NAME="3AG Accounts" APP_ENV=local APP_KEY= APP_DEBUG=true @@ -20,12 +20,15 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +# This app holds the identities for every 3AG product, and `deploy.php` shares +# only `storage` and `.env` — a SQLite file under `database/` would be replaced +# on every release. MySQL, always. +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=accounts +DB_USERNAME=accounts +DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 @@ -62,4 +65,17 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false +# Passport signs every access and ID token with this key pair, and publishes +# the public half at /oauth/jwks. It must stay identical across deploys or +# every live session breaks. Generate once with `php artisan passport:keys` +# and paste the PEM contents here, with literal \n for the line breaks. +PASSPORT_PRIVATE_KEY= +PASSPORT_PUBLIC_KEY= + +# Where each product lives. These drive the OAuth redirect URIs registered by +# the client seeder, so production must set all three. +CLIENT_SALESREPORT_URL=http://localhost:8001 +CLIENT_PRODUCTSYNCMANAGER_URL=http://localhost:8002 +CLIENT_COMPLIANCEPLATFORM_URL=http://localhost:8003 + VITE_APP_NAME="${APP_NAME}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca4026e..543ca62 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,31 @@ jobs: ci: runs-on: ubuntu-latest + # Production runs MySQL, and this app's schema is mostly Passport's, which + # leans on string primary keys and unique indexes. SQLite would wave those + # through, so the suite runs against the real engine. + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: accounts + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: accounts + DB_USERNAME: root + DB_PASSWORD: '' + steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -32,7 +57,9 @@ jobs: with: node-version: '22' - # composer setup writes .env, generates a key, migrates sqlite and builds assets. + # composer setup writes .env, generates a key and the Passport signing + # keys, migrates the database and builds assets. The DB_* variables above + # take precedence over the ones it copies out of .env.example. - name: Setup Application run: composer setup diff --git a/CLAUDE.md b/CLAUDE.md index af751d9..75c3688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,16 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. - Activate the `deploying-to-cloud` skill whenever deploying to Laravel Cloud, configuring Cloud environments or resources, using the Cloud CLI, or troubleshooting Cloud deployments. +=== tests rules === + +# Test Enforcement + +- Add or update tests for behavior and logic changes when a test provides meaningful regression coverage. +- Pure copy, styling, and layout-only changes do not require new or updated tests. +- When test coverage applies, run the affected tests and ensure they pass. +- Test the changed behavior and its important failure modes, but do not add tests beyond them. +- Read the `testing-best-practices` skill before writing tests. + === laravel/core rules === # Do Things the Laravel Way diff --git a/README.md b/README.md index 5ad1377..3e01c3a 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,151 @@ -

Laravel Logo

+# 3AG Accounts -

-Build Status -Total Downloads -Latest Stable Version -License -

+The identity provider for the 3AG products. A client signs in once at +[accounts.3ag.app](https://accounts.3ag.app), and SalesReport, +ProductSyncManager and CompliancePlatform sign them in from there over OpenID +Connect. -## About Laravel +This app owns identity and nothing else. Each product keeps its own codebase, +its own database, its own `APP_KEY` and its own host-only session cookie. The +only thing that crosses the boundary is a signed ID token. -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +## How it works -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +Built on [Laravel Passport](https://laravel.com/docs/passport) for the OAuth 2.1 +machinery, with a small OIDC layer on top of it in `app/Oidc`, because Passport +ships no ID tokens, discovery document, JWKS or userinfo endpoint of its own. -Laravel is accessible, powerful, and provides tools required for large, robust applications. +| Endpoint | Purpose | +| --- | --- | +| `GET /.well-known/openid-configuration` | Provider metadata | +| `GET /oauth/authorize` | Authorization request (Passport) | +| `POST /oauth/token` | Token exchange (Passport) | +| `GET /oauth/userinfo` | Claims for an access token | +| `GET /oauth/jwks` | Public signing key | +| `GET /oauth/logout` | RP-initiated logout | -## Learning Laravel +Supported: authorization code with PKCE (`S256`) and refresh tokens. ID tokens +are RS256, signed with Passport's key pair. Scopes are `openid`, `profile` and +`email`. -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. +The `sub` claim is the user's `public_id`, a ULID — not the primary key. A +product should store that and treat it as the identity, because it survives a +change of name or email address. -In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. +### Two things worth knowing -You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. +**First-party clients skip the consent screen.** A client with `first_party` +set is one of ours, and a signed-in user is sent straight back to it. Any other +client gets the Blade consent page at `resources/views/oauth/authorize.blade.php`. -## Agentic Development +**Access is granted per product.** A user with no row in `client_user` for a +client cannot sign in to it, and is shown "You don't have access to X" instead +of an authorization code. Products still own their own roles and permissions; +this is only the front door. -Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: +## Running an account -```bash -composer require laravel/boost --dev +There is no public registration. -php artisan boost:install +```bash +php artisan accounts:create-user ada@example.com --name="Ada Lovelace" --grant=SalesReport +php artisan accounts:grant ada@example.com ProductSyncManager +php artisan accounts:revoke ada@example.com SalesReport ``` -Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. +`accounts:create-user` emails a verification link and a link to set a password. +`accounts:revoke` removes the grant *and* revokes the tokens the user already +holds for that product, so it takes effect immediately rather than at their next +sign-in. + +## Local development -## Contributing +MySQL, same as production. Create the database, then: + +```bash +composer setup +php artisan db:seed +composer run dev +``` -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +The seeder registers the three products as first-party clients and prints each +client id and secret **once** — the secret is hashed on save. It expects +Accounts on port 8000 and the products on 8001–8003; override with +`CLIENT_SALESREPORT_URL` and friends. -## Code of Conduct +In local mode it also creates `test@example.com` / `password`, with access to +every product. -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +The test suite runs on in-memory SQLite for speed (`phpunit.xml`), but CI runs +it against MySQL, because that is what production is. Real environment +variables beat the ones in `phpunit.xml`, so you can do the same locally: -## Security Vulnerabilities +```bash +DB_CONNECTION=mysql DB_DATABASE=accounts_test php artisan test --compact +``` -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +Point it at a database of its own. `RefreshDatabase` starts by dropping every +table, so aiming it at `accounts` costs you your development data and reissues +every client secret. + +## Deployment notes + +- **The signing keys must be stable.** Generate them once and put them in the + server's `.env` as `PASSPORT_PRIVATE_KEY` and `PASSPORT_PUBLIC_KEY` (PEM, with + literal `\n` for line breaks). Keys under `storage/` would work too, but env + keeps them in one place with the rest of the secrets. Rotating them + invalidates every live token and every published JWK. +- **`SESSION_DOMAIN` stays `null`.** A host-only cookie on `accounts.3ag.app`. + Setting it to `.3ag.app` would share this app's session with every product, + which is the thing this design exists to avoid. +- **MySQL, not SQLite.** `deploy.php` shares only `storage` and `.env`, so a + SQLite file under `database/` would be replaced on every release, taking + every identity with it. Create the database and a user for it on the server, + and set `DB_*` in the shared `.env`. The first deploy needs + `php artisan migrate --force` against the empty schema. + +## Adding a product + +The client side is about a hundred lines. SalesReport has a working copy to +crib from: `app/Services/Auth/ThreeAgProvider.php`, +`app/Http/Controllers/Auth/ThreeAgCallbackController.php` and +`app/Http/Middleware/ThreeAgSingleSignOn.php`. + +1. **Register the client here.** Add the product to `config/products.php` and + run `php artisan db:seed --class=ClientSeeder`. Keep the printed secret. + +2. **In the product**, install `laravel/socialite` and add a + `Socialite\Two\AbstractProvider` pointing at this app's endpoints, with + `$usesPKCE = true` and scopes `openid profile email`. Register it with + `Socialite::extend('3ag', ...)`. + +3. **Add `users.oidc_sub`**, a nullable unique string, and two routes: + `GET /auth/accounts/redirect` and `GET /auth/accounts/callback`. + +4. **In the callback**, resolve the local user in this order: by `oidc_sub`; + then, *only if the provider reports `email_verified`*, by email, backfilling + `oidc_sub`; then create one. Skipping the `email_verified` check would let + anyone who can set an unverified address here claim an existing account in + the product. + +5. **Configure** `THREE_AG_BASE_URL`, `THREE_AG_CLIENT_ID` and + `THREE_AG_CLIENT_SECRET`. Leave `THREE_AG_SSO_ONLY=false` until the flow is + proven in production; turning it on removes the product's own login, + registration and password reset, and makes logging out end the session here + as well. + +6. **Grant access**: `php artisan accounts:grant someone@example.com TheProduct`. + Until you do, they will be refused — which is the point. + +## Tests -## License +```bash +php artisan test --compact +``` -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +The suite covers the whole flow rather than the pieces: a real authorization +request through to an ID token verified against the published JWKS, the consent +screen appearing for third parties and not for us, the access gate refusing to +issue a code, `nonce` surviving the consent POST, refreshed tokens correctly +*not* carrying a nonce, and the logout endpoint honouring only registered +redirect URIs. diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..3678865 --- /dev/null +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,19 @@ +|string> + */ + protected function passwordRules(): array + { + return ['required', 'string', Password::default(), 'confirmed']; + } +} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..667651f --- /dev/null +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,32 @@ + $input + * + * @throws ValidationException + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserPassword.php b/app/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..4a0306d --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,35 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..62f58fa --- /dev/null +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,61 @@ + $input + * + * @throws ValidationException + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + Rule::unique('users')->ignore($user->id), + ], + ])->validateWithBag('updateProfileInformation'); + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/app/Console/Commands/Concerns/ResolvesAccountsAndClients.php b/app/Console/Commands/Concerns/ResolvesAccountsAndClients.php new file mode 100644 index 0000000..a9315b1 --- /dev/null +++ b/app/Console/Commands/Concerns/ResolvesAccountsAndClients.php @@ -0,0 +1,45 @@ +argument('email'); + $identifier = (string) $this->argument('client'); + + $user = User::query()->where('email', $email)->first(); + + if ($user === null) { + $this->components->error("No account found for [{$email}]."); + + return [null, null]; + } + + $client = Passport::client()->newQuery() + ->where('name', $identifier) + ->orWhere('id', $identifier) + ->first(); + + if (! $client instanceof Client) { + $this->components->error("No client found for [{$identifier}]."); + + return [$user, null]; + } + + return [$user, $client]; + } +} diff --git a/app/Console/Commands/CreateUserCommand.php b/app/Console/Commands/CreateUserCommand.php new file mode 100644 index 0000000..309f610 --- /dev/null +++ b/app/Console/Commands/CreateUserCommand.php @@ -0,0 +1,74 @@ +argument('email'); + $name = (string) ($this->option('name') ?: $this->ask('Name')); + + $validator = Validator::make(['email' => $email, 'name' => $name], [ + 'email' => ['required', 'email', Rule::unique(User::class, 'email')], + 'name' => ['required', 'string', 'max:255'], + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $message) { + $this->components->error($message); + } + + return self::FAILURE; + } + + $user = User::query()->create([ + 'name' => $name, + 'email' => $email, + 'password' => Str::password(32), + ]); + + foreach ($this->option('grant') as $identifier) { + $this->call('accounts:grant', ['email' => $user->email, 'client' => $identifier]); + } + + $user->sendEmailVerificationNotification(); + + Password::sendResetLink(['email' => $user->email]); + + $this->components->info("Created {$user->email}. A link to set their password is on its way."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/GrantClientAccessCommand.php b/app/Console/Commands/GrantClientAccessCommand.php new file mode 100644 index 0000000..263834b --- /dev/null +++ b/app/Console/Commands/GrantClientAccessCommand.php @@ -0,0 +1,51 @@ +resolve(); + + if ($user === null || $client === null) { + return self::FAILURE; + } + + $user->clients()->syncWithoutDetaching([ + $client->getKey() => ['granted_at' => Carbon::now()], + ]); + + $this->components->info("{$user->email} can now sign in to {$client->name}."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RevokeClientAccessCommand.php b/app/Console/Commands/RevokeClientAccessCommand.php new file mode 100644 index 0000000..bb05235 --- /dev/null +++ b/app/Console/Commands/RevokeClientAccessCommand.php @@ -0,0 +1,69 @@ +resolve(); + + if ($user === null || $client === null) { + return self::FAILURE; + } + + $user->clients()->detach($client->getKey()); + + // Removing the grant only stops the next sign-in. The tokens they + // already hold have to go too, and the refresh tokens with them: + // Passport checks a refresh token's own `revoked` flag and never looks + // at the access token behind it, so leaving them would let the user + // keep minting access tokens for the life of the refresh token. + $tokenIds = Passport::token()->newQuery() + ->where('user_id', $user->getKey()) + ->where('client_id', $client->getKey()) + ->where('revoked', false) + ->pluck('id'); + + Passport::refreshToken()->newQuery() + ->whereIn('access_token_id', $tokenIds) + ->update(['revoked' => true]); + + Passport::token()->newQuery()->whereKey($tokenIds)->update(['revoked' => true]); + + $this->components->info( + "{$user->email} can no longer sign in to {$client->name}. Revoked {$tokenIds->count()} active token(s)." + ); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..8dc82e7 --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,19 @@ + $request->user()->clients()->orderBy('name')->get(), + ]); + } +} diff --git a/app/Http/Controllers/Oidc/DiscoveryController.php b/app/Http/Controllers/Oidc/DiscoveryController.php new file mode 100644 index 0000000..45b74b9 --- /dev/null +++ b/app/Http/Controllers/Oidc/DiscoveryController.php @@ -0,0 +1,38 @@ +json([ + 'issuer' => $idTokens->issuer(), + 'authorization_endpoint' => route('passport.authorizations.authorize'), + 'token_endpoint' => route('passport.token'), + 'userinfo_endpoint' => route('oidc.userinfo'), + 'jwks_uri' => route('oidc.jwks'), + 'end_session_endpoint' => route('oidc.logout'), + 'scopes_supported' => ['openid', 'profile', 'email'], + 'response_types_supported' => ['code'], + 'response_modes_supported' => ['query'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'code_challenge_methods_supported' => ['S256'], + 'claims_supported' => [ + 'iss', 'aud', 'sub', 'iat', 'exp', 'auth_time', 'nonce', 'at_hash', + 'name', 'updated_at', 'email', 'email_verified', + ], + ]); + } +} diff --git a/app/Http/Controllers/Oidc/EndSessionController.php b/app/Http/Controllers/Oidc/EndSessionController.php new file mode 100644 index 0000000..bdde613 --- /dev/null +++ b/app/Http/Controllers/Oidc/EndSessionController.php @@ -0,0 +1,120 @@ +destinationFor($request); + + if (Auth::check()) { + Auth::logout(); + } + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->away($destination); + } + + /** + * Resolve where to send the user after logging out. + * + * A redirect target is only honoured when the ID token hint proves which + * client is asking and that client has the URI registered. Anything else + * lands back on our own dashboard, so this endpoint can never be used as + * an open redirect. + */ + protected function destinationFor(Request $request): string + { + $requested = $request->query('post_logout_redirect_uri'); + + if (! is_string($requested) || $requested === '') { + return route('dashboard'); + } + + $client = $this->clientFromTokenHint($request->query('id_token_hint')); + + if (! $client instanceof Client || ! $client->hasPostLogoutRedirectUri($requested)) { + return route('dashboard'); + } + + $state = $request->query('state'); + + return is_string($state) && $state !== '' + ? $requested.(str_contains($requested, '?') ? '&' : '?').http_build_query(['state' => $state]) + : $requested; + } + + /** + * Identify the client that issued the given ID token. + */ + protected function clientFromTokenHint(mixed $hint): ?Client + { + if (! is_string($hint) || $hint === '') { + return null; + } + + $configuration = Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($this->key->privateKey()), + InMemory::plainText($this->key->publicKey()), + ); + + try { + $token = $configuration->parser()->parse($hint); + + $configuration->validator()->assert( + $token, + new SignedWith($configuration->signer(), $configuration->verificationKey()), + new IssuedBy($this->idTokens->issuer()), + ); + } catch (Throwable) { + return null; + } + + if (! $token instanceof UnencryptedToken) { + return null; + } + + $audience = $token->claims()->get('aud', []); + $clientId = is_array($audience) ? ($audience[0] ?? null) : $audience; + + if (! is_string($clientId)) { + return null; + } + + $client = Passport::client()->newQuery()->whereKey($clientId)->first(); + + return $client instanceof Client ? $client : null; + } +} diff --git a/app/Http/Controllers/Oidc/JwksController.php b/app/Http/Controllers/Oidc/JwksController.php new file mode 100644 index 0000000..bf56c66 --- /dev/null +++ b/app/Http/Controllers/Oidc/JwksController.php @@ -0,0 +1,20 @@ +json(['keys' => [$key->jsonWebKey()]]) + ->header('Cache-Control', 'public, max-age=3600'); + } +} diff --git a/app/Http/Controllers/Oidc/UserInfoController.php b/app/Http/Controllers/Oidc/UserInfoController.php new file mode 100644 index 0000000..ee07c94 --- /dev/null +++ b/app/Http/Controllers/Oidc/UserInfoController.php @@ -0,0 +1,31 @@ +user(); + + $scopes = array_filter( + ['profile', 'email'], + fn (string $scope): bool => $user->tokenCan($scope) + ); + + return response()->json([ + 'sub' => $user->public_id, + ...$idTokens->claimsFor($user, $scopes), + ]); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..3ba23ee --- /dev/null +++ b/app/Http/Controllers/SettingsController.php @@ -0,0 +1,19 @@ + $request->user(), + ]); + } +} diff --git a/app/Http/Middleware/CaptureOidcAuthorizeParameters.php b/app/Http/Middleware/CaptureOidcAuthorizeParameters.php new file mode 100644 index 0000000..9f7ecd1 --- /dev/null +++ b/app/Http/Middleware/CaptureOidcAuthorizeParameters.php @@ -0,0 +1,45 @@ +routeIs('passport.authorizations.authorize')) { + return $next($request); + } + + $maxAge = $request->has('max_age') ? max(0, $request->integer('max_age')) : null; + + $this->session->rememberAuthorizeParameters($request->query('nonce'), $maxAge); + + // A client asking for a fresh authentication gets one. Passport already + // knows how to force a re-login for `prompt=login`, so we reuse it + // rather than logging the user out ourselves. + if ($maxAge !== null && $this->session->isStalerThan($maxAge)) { + $request->query->set('prompt', trim($request->query('prompt', '').' login')); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureUserCanAccessClient.php b/app/Http/Middleware/EnsureUserCanAccessClient.php new file mode 100644 index 0000000..619ea36 --- /dev/null +++ b/app/Http/Middleware/EnsureUserCanAccessClient.php @@ -0,0 +1,81 @@ +routeIs('passport.authorizations.authorize', 'passport.authorizations.approve')) { + return $next($request); + } + + $user = $request->user(); + $clientId = $this->clientId($request); + + // An unauthenticated user is Passport's problem, not ours: it will send + // them to the login page and bring them back here afterwards. + if ($user === null || $clientId === null) { + return $next($request); + } + + $client = Passport::client()->newQuery()->whereKey($clientId)->first(); + + if ($client instanceof Client && ! $user->canAccessClient($client)) { + return response()->view('oauth.access-denied', ['client' => $client], Response::HTTP_FORBIDDEN); + } + + return $next($request); + } + + /** + * Resolve the client the request is about. + * + * The authorization request carries the client in the query string; the + * approval that follows it only has the request stashed in the session. + */ + protected function clientId(Request $request): ?string + { + if ($request->filled('client_id')) { + return $request->string('client_id')->toString(); + } + + try { + $authRequest = unserialize((string) $request->session()->get('authRequest'), ['allowed_classes' => [ + AuthorizationRequest::class, + \Laravel\Passport\Bridge\Client::class, + Scope::class, + User::class, + ]]); + } catch (Throwable) { + return null; + } + + return $authRequest instanceof AuthorizationRequestInterface + ? $authRequest->getClient()->getIdentifier() + : null; + } +} diff --git a/app/Models/Client.php b/app/Models/Client.php new file mode 100644 index 0000000..c3e7eb5 --- /dev/null +++ b/app/Models/Client.php @@ -0,0 +1,79 @@ + + */ + protected $casts = [ + 'grant_types' => 'array', + 'scopes' => 'array', + 'redirect_uris' => 'array', + 'post_logout_redirect_uris' => 'array', + 'personal_access_client' => 'bool', + 'password_client' => 'bool', + 'revoked' => 'bool', + 'first_party' => 'bool', + ]; + + /** + * The users who have been granted access to this client. + * + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class)->withPivot('granted_at'); + } + + /** + * Determine if the client should skip the authorization prompt. + * + * Our own products are trusted, so a signed-in user is never asked to + * consent to them. Anything else gets the consent screen. + * + * @param Scope[] $scopes + */ + public function skipsAuthorization(Authenticatable $user, array $scopes): bool + { + return $this->first_party; + } + + /** + * Get the product's own address, derived from where it asks us to + * redirect after a sign-in. + */ + public function homeUrl(): ?string + { + $redirectUri = $this->redirect_uris[0] ?? null; + + if ($redirectUri === null) { + return null; + } + + $parts = parse_url($redirectUri); + + if (! isset($parts['scheme'], $parts['host'])) { + return null; + } + + return $parts['scheme'].'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : ''); + } + + /** + * Determine if the given URI is registered for post-logout redirects. + */ + public function hasPostLogoutRedirectUri(string $uri): bool + { + return in_array($uri, $this->post_logout_redirect_uris ?? [], true); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f6ba1d2..0fdd1e6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,20 +2,35 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Str; +use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Passport\Contracts\OAuthenticatable; +use Laravel\Passport\HasApiTokens; #[Fillable(['name', 'email', 'password'])] -#[Hidden(['password', 'remember_token'])] -class User extends Authenticatable +#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] +class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; + + /** + * Assign the opaque public identifier that clients see as the OIDC subject. + */ + protected static function booted(): void + { + static::creating(function (User $user): void { + $user->public_id ??= (string) Str::ulid(); + }); + } /** * Get the attributes that should be cast. @@ -27,6 +42,25 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'two_factor_confirmed_at' => 'datetime', ]; } + + /** + * The clients this user has been granted access to. + * + * @return BelongsToMany + */ + public function clients(): BelongsToMany + { + return $this->belongsToMany(Client::class)->withPivot('granted_at'); + } + + /** + * Determine if the user may sign in to the given client. + */ + public function canAccessClient(Client $client): bool + { + return $this->clients()->whereKey($client->getKey())->exists(); + } } diff --git a/app/Oidc/AuthorizeContext.php b/app/Oidc/AuthorizeContext.php new file mode 100644 index 0000000..f3ffa30 --- /dev/null +++ b/app/Oidc/AuthorizeContext.php @@ -0,0 +1,28 @@ +nonce = $nonce; + $this->authenticatedAt = $authenticatedAt; + } +} diff --git a/app/Oidc/AuthorizeSession.php b/app/Oidc/AuthorizeSession.php new file mode 100644 index 0000000..f364146 --- /dev/null +++ b/app/Oidc/AuthorizeSession.php @@ -0,0 +1,89 @@ +session->put(self::NONCE, $nonce); + $this->session->put(self::MAX_AGE, $maxAge); + } + + /** + * Get the nonce of the authorization request being processed. + */ + public function nonce(): ?string + { + return $this->session->get(self::NONCE); + } + + /** + * Get the `max_age` of the authorization request being processed. + */ + public function maxAge(): ?int + { + return $this->session->get(self::MAX_AGE); + } + + /** + * Forget the parameters of the authorization request being processed. + */ + public function forgetAuthorizeParameters(): void + { + $this->session->forget([self::NONCE, self::MAX_AGE]); + } + + /** + * Record the moment the user authenticated with this session. + */ + public function markAuthenticatedNow(): void + { + $this->session->put(self::AUTHENTICATED_AT, Carbon::now()->getTimestamp()); + } + + /** + * Get the moment the user authenticated with this session. + */ + public function authenticatedAt(): ?Carbon + { + $timestamp = $this->session->get(self::AUTHENTICATED_AT); + + return $timestamp === null ? null : Carbon::createFromTimestamp($timestamp); + } + + /** + * Determine if the session is older than the given `max_age` allows. + */ + public function isStalerThan(int $maxAge): bool + { + $authenticatedAt = $this->authenticatedAt(); + + return $authenticatedAt === null + || $authenticatedAt->addSeconds($maxAge)->isPast(); + } +} diff --git a/app/Oidc/IdTokenBuilder.php b/app/Oidc/IdTokenBuilder.php new file mode 100644 index 0000000..dfa371f --- /dev/null +++ b/app/Oidc/IdTokenBuilder.php @@ -0,0 +1,143 @@ +find($accessToken->getUserIdentifier()); + + if (! $user instanceof User) { + throw new RuntimeException('Unable to issue an ID token for an unknown user.'); + } + + $scopes = $this->scopeIdentifiers($accessToken); + $issuedAt = new DateTimeImmutable; + + $builder = $this->configuration()->builder() + ->withHeader('kid', $this->key->keyId()) + ->issuedBy($this->issuer()) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->relatedTo($user->public_id) + ->issuedAt($issuedAt) + ->expiresAt($accessToken->getExpiryDateTime()) + ->withClaim('at_hash', $this->accessTokenHash($accessToken)); + + if ($this->context->authenticatedAt !== null) { + $builder = $builder->withClaim('auth_time', $this->context->authenticatedAt->getTimestamp()); + } + + // A token re-issued through the refresh grant has no authorization + // request behind it, so it carries no nonce. That is what the spec asks + // for, and relying parties only check the nonce on a fresh login. + if ($this->context->nonce !== null) { + $builder = $builder->withClaim('nonce', $this->context->nonce); + } + + foreach ($this->claimsFor($user, $scopes) as $claim => $value) { + $builder = $builder->withClaim($claim, $value); + } + + return $builder->getToken( + $this->configuration()->signer(), + $this->configuration()->signingKey() + )->toString(); + } + + /** + * Get the claims the granted scopes entitle the client to. + * + * @param string[] $scopes + * @return array + */ + public function claimsFor(User $user, array $scopes): array + { + $claims = []; + + if (in_array('profile', $scopes, true)) { + $claims['name'] = $user->name; + $claims['updated_at'] = $user->updated_at?->getTimestamp(); + } + + if (in_array('email', $scopes, true)) { + $claims['email'] = $user->email; + $claims['email_verified'] = $user->hasVerifiedEmail(); + } + + return $claims; + } + + /** + * Get the issuer identifier, which must match the discovery document. + */ + public function issuer(): string + { + return rtrim((string) $this->config->get('app.url'), '/'); + } + + /** + * Get the scope identifiers granted to the given access token. + * + * @return string[] + */ + protected function scopeIdentifiers(AccessTokenEntityInterface $accessToken): array + { + return array_map( + fn (ScopeEntityInterface $scope): string => $scope->getIdentifier(), + $accessToken->getScopes() + ); + } + + /** + * Hash the access token so the client can tie it to this ID token. + * + * Per OIDC core this is the base64url encoded left-most half of the + * SHA-256 digest, the hash matching the RS256 signing algorithm. + */ + protected function accessTokenHash(AccessTokenEntityInterface $accessToken): string + { + $digest = hash('sha256', $accessToken->toString(), true); + + return rtrim(strtr(base64_encode(substr($digest, 0, 16)), '+/', '-_'), '='); + } + + /** + * Get the JWT configuration bound to Passport's key pair. + */ + protected function configuration(): Configuration + { + return $this->configuration ??= Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($this->key->privateKey()), + InMemory::plainText($this->key->publicKey()), + ); + } +} diff --git a/app/Oidc/IdTokenResponse.php b/app/Oidc/IdTokenResponse.php new file mode 100644 index 0000000..1c6e86c --- /dev/null +++ b/app/Oidc/IdTokenResponse.php @@ -0,0 +1,61 @@ + + */ + protected function getExtraParams( + #[SensitiveParameter] + AccessTokenEntityInterface $accessToken + ): array { + if (! $this->grantsOpenIdScope($accessToken)) { + return []; + } + + return ['id_token' => $this->idTokens->build($accessToken)]; + } + + /** + * Determine if the access token was granted the `openid` scope. + */ + protected function grantsOpenIdScope(AccessTokenEntityInterface $accessToken): bool + { + foreach ($accessToken->getScopes() as $scope) { + if ($scope instanceof ScopeEntityInterface && $scope->getIdentifier() === 'openid') { + return true; + } + } + + return false; + } +} diff --git a/app/Oidc/MemoizedAccessToken.php b/app/Oidc/MemoizedAccessToken.php new file mode 100644 index 0000000..5f4b0b0 --- /dev/null +++ b/app/Oidc/MemoizedAccessToken.php @@ -0,0 +1,91 @@ +serialized ??= $this->token->toString(); + } + + public function setPrivateKey(CryptKeyInterface $privateKey): void + { + $this->token->setPrivateKey($privateKey); + } + + public function getIdentifier(): string + { + return $this->token->getIdentifier(); + } + + public function setIdentifier(string $identifier): void + { + $this->token->setIdentifier($identifier); + } + + public function getExpiryDateTime(): DateTimeImmutable + { + return $this->token->getExpiryDateTime(); + } + + public function setExpiryDateTime(DateTimeImmutable $dateTime): void + { + $this->token->setExpiryDateTime($dateTime); + } + + public function setUserIdentifier(string $identifier): void + { + $this->token->setUserIdentifier($identifier); + } + + public function getUserIdentifier(): ?string + { + return $this->token->getUserIdentifier(); + } + + public function getClient(): ClientEntityInterface + { + return $this->token->getClient(); + } + + public function setClient(ClientEntityInterface $client): void + { + $this->token->setClient($client); + } + + public function addScope(ScopeEntityInterface $scope): void + { + $this->token->addScope($scope); + } + + /** + * {@inheritdoc} + */ + public function getScopes(): array + { + return $this->token->getScopes(); + } +} diff --git a/app/Oidc/SigningKey.php b/app/Oidc/SigningKey.php new file mode 100644 index 0000000..638f2a0 --- /dev/null +++ b/app/Oidc/SigningKey.php @@ -0,0 +1,127 @@ +privateKey ??= $this->readKey('private'); + } + + /** + * Get the PEM encoded public key relying parties verify with. + */ + public function publicKey(): string + { + return $this->publicKey ??= $this->readKey('public'); + } + + /** + * Get the RFC 7638 thumbprint that identifies the key. + */ + public function keyId(): string + { + if (isset($this->keyId)) { + return $this->keyId; + } + + ['n' => $modulus, 'e' => $exponent] = $this->parameters(); + + $canonical = sprintf('{"e":"%s","kty":"RSA","n":"%s"}', $exponent, $modulus); + + return $this->keyId = $this->base64UrlEncode(hash('sha256', $canonical, true)); + } + + /** + * Get the public key as a JSON Web Key. + * + * @return array{kty: string, use: string, alg: string, kid: string, n: string, e: string} + */ + public function jsonWebKey(): array + { + ['n' => $modulus, 'e' => $exponent] = $this->parameters(); + + return [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'kid' => $this->keyId(), + 'n' => $modulus, + 'e' => $exponent, + ]; + } + + /** + * Get the base64url encoded RSA modulus and exponent of the public key. + * + * @return array{n: string, e: string} + */ + protected function parameters(): array + { + $key = openssl_pkey_get_public($this->publicKey()); + + if ($key === false) { + throw new RuntimeException('The Passport public key is not a valid PEM encoded key.'); + } + + $details = openssl_pkey_get_details($key); + + if ($details === false || ($details['type'] ?? null) !== OPENSSL_KEYTYPE_RSA) { + throw new RuntimeException('The Passport public key must be an RSA key to sign ID tokens.'); + } + + return [ + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ]; + } + + /** + * Read a key from the environment, falling back to Passport's key path. + */ + protected function readKey(string $type): string + { + $key = str_replace('\\n', "\n", (string) config("passport.{$type}_key")); + + if ($key !== '') { + return $key; + } + + $path = Passport::keyPath('oauth-'.$type.'.key'); + + if (! is_readable($path)) { + throw new RuntimeException( + "Unable to read the Passport {$type} key. Run [php artisan passport:keys] or set the PASSPORT_".strtoupper($type).'_KEY environment variable.' + ); + } + + return (string) file_get_contents($path); + } + + /** + * Encode the given bytes without padding, as JOSE requires. + */ + protected function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/app/Passport/AuthCodeRepository.php b/app/Passport/AuthCodeRepository.php new file mode 100644 index 0000000..32e9d18 --- /dev/null +++ b/app/Passport/AuthCodeRepository.php @@ -0,0 +1,40 @@ +forceFill([ + 'id' => $authCodeEntity->getIdentifier(), + 'user_id' => $authCodeEntity->getUserIdentifier(), + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'scopes' => json_encode($authCodeEntity->getScopes()), + 'nonce' => $this->session->nonce(), + 'auth_time' => $this->session->authenticatedAt(), + 'revoked' => false, + 'expires_at' => $authCodeEntity->getExpiryDateTime(), + ])->save(); + + $this->session->forgetAuthorizeParameters(); + } +} diff --git a/app/Passport/ScopeRepository.php b/app/Passport/ScopeRepository.php new file mode 100644 index 0000000..6b02d07 --- /dev/null +++ b/app/Passport/ScopeRepository.php @@ -0,0 +1,52 @@ +newQuery()->whereKey($authCodeId)->first(); + + // Always written, never merely added to: the refresh grant reaches + // here with no authorization code, and its ID token must not inherit + // the nonce of the login that came before it. + $this->context->remember( + $authCode?->nonce, + $authCode?->auth_time ? Carbon::parse($authCode->auth_time) : null, + ); + + return parent::finalizeScopes($scopes, $grantType, $clientEntity, $userIdentifier, $authCodeId); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..849428c --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,88 @@ +configureActions(); + $this->configureViews(); + $this->configureRateLimiting(); + $this->recordAuthenticationTime(); + } + + /** + * Configure the Fortify actions. + * + * Registration is deliberately absent: accounts are created by an + * administrator, so there is no `createUsersUsing` binding to make. + */ + protected function configureActions(): void + { + Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class); + Fortify::updateUserPasswordsUsing(UpdateUserPassword::class); + Fortify::resetUserPasswordsUsing(ResetUserPassword::class); + Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class); + } + + /** + * Point Fortify at our Blade views. + */ + protected function configureViews(): void + { + Fortify::loginView(fn () => view('auth.login')); + Fortify::requestPasswordResetLinkView(fn () => view('auth.forgot-password')); + Fortify::resetPasswordView(fn (Request $request) => view('auth.reset-password', ['request' => $request])); + Fortify::verifyEmailView(fn () => view('auth.verify-email')); + Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge')); + Fortify::confirmPasswordView(fn () => view('auth.confirm-password')); + } + + /** + * Configure the rate limiters guarding the authentication endpoints. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('login', function (Request $request) { + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); + + return Limit::perMinute(5)->by($throttleKey); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } + + /** + * Remember when each session authenticated. + * + * OIDC relying parties can ask how fresh a login is, through the + * `auth_time` claim and the `max_age` authorization parameter, and neither + * can be answered from the session's own lifetime. + */ + protected function recordAuthenticationTime(): void + { + Event::listen(function (Login $event): void { + $this->app->make(AuthorizeSession::class)->markAuthenticatedNow(); + }); + } +} diff --git a/app/Providers/PassportServiceProvider.php b/app/Providers/PassportServiceProvider.php new file mode 100644 index 0000000..2312171 --- /dev/null +++ b/app/Providers/PassportServiceProvider.php @@ -0,0 +1,60 @@ +app->singleton(AuthorizeContext::class); + + // Both bridges extend Passport's own, adding only the handling of the + // OIDC parameters that have to survive the authorization code. + $this->app->singleton(Bridge\AuthCodeRepository::class, AuthCodeRepository::class); + $this->app->singleton(Bridge\ScopeRepository::class, ScopeRepository::class); + + // Routes are registered when Passport boots, which is before this + // provider boots, so the grant has to be switched off during register. + Passport::$deviceCodeGrantEnabled = false; + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + Passport::useClientModel(Client::class); + + Passport::useAuthorizationServerResponseType( + $this->app->make(IdTokenResponse::class) + ); + + Passport::authorizationView('oauth.authorize'); + + Passport::tokensCan([ + 'openid' => 'Verify your identity', + 'profile' => 'Read your name', + 'email' => 'Read your email address', + ]); + + Passport::tokensExpireIn(now()->addHour()); + Passport::refreshTokensExpireIn(now()->addDays(30)); + } +} diff --git a/boost.json b/boost.json index 7420140..344d310 100644 --- a/boost.json +++ b/boost.json @@ -9,8 +9,10 @@ "sail": false, "skills": [ "infer-conventions", + "fortify-development", "laravel-best-practices", "testing-best-practices", + "passport-development", "tailwindcss-development" ] } diff --git a/bootstrap/app.php b/bootstrap/app.php index 1085719..95eedcb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ withMiddleware(function (Middleware $middleware): void { - // + // Both of these read the session, so they have to run after the web + // group has started one. They no-op on every route but Passport's + // authorization endpoints. + $middleware->appendToGroup('web', [ + CaptureOidcAuthorizeParameters::class, + EnsureUserCanAccessClient::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + // The userinfo endpoint is machine-to-machine whatever the caller + // sends in Accept. Redirecting it to the login page would hand a + // relying party an HTML page where it expects a 401. + fn (Request $request) => $request->is('api/*') + || $request->routeIs('oidc.userinfo') + || $request->expectsJson(), ); })->create(); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..56a3f96 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,11 @@ =7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -214,6 +386,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -512,6 +732,72 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a", + "reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4||^8.0", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.1" + }, + "time": "2026-09-14T17:48:47+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1059,6 +1345,70 @@ ], "time": "2026-08-24T17:13:02+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.39.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a", + "reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.2.6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-08-23T07:46:41+00:00" + }, { "name": "laravel/framework", "version": "v13.32.0", @@ -1291,36 +1641,179 @@ "time": "2026-09-15T14:55:30+00:00" }, { - "name": "laravel/prompts", - "version": "v0.3.24", + "name": "laravel/passkeys", + "version": "v0.2.1", "source": { "type": "git", - "url": "https://github.com/laravel/prompts.git", - "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", - "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", "shasum": "" }, "require": { - "composer-runtime-api": "^2.2", - "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.2|^7.0|^8.0" - }, - "conflict": { - "illuminate/console": ">=10.17.0 <10.25.0", - "laravel/framework": ">=10.17.0 <10.25.0" + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" }, "require-dev": { - "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.5", - "pestphp/pest": "^2.3|^3.4|^4.0", - "phpstan/phpstan": "^1.12.28", - "phpstan/phpstan-mockery": "^1.1.3" - }, + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, + { + "name": "laravel/passport", + "version": "v13.8.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "63118b38b508d5605a0e3b5468d447bf540ea7d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/63118b38b508d5605a0e3b5468d447bf540ea7d2", + "reference": "63118b38b508d5605a0e3b5468d447bf540ea7d2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^4.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-08-28T14:24:54+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, "suggest": { "ext-pcntl": "Required for the spinner to be animated." }, @@ -1479,6 +1972,79 @@ }, "time": "2026-03-17T14:54:13+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.10.1", @@ -1668,6 +2234,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.36.0", @@ -1856,6 +2481,103 @@ ], "time": "2026-07-09T11:49:27+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/jwt": "^5.6", + "league/event": "^3.0", + "league/uri": "^7.8", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.8", + "paragonie/random_compat": "^9.99.100", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.50", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.27.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2026-06-25T15:24:07+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -2551,155 +3273,174 @@ "time": "2026-02-16T23:10:27+00:00" }, { - "name": "phpoption/phpoption", - "version": "1.10.0", + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", "source": { "type": "git", - "url": "https://github.com/schmittjoh/php-option.git", - "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", - "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^8" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" }, "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, - "branch-alias": { - "dev-master": "1.9-dev" - } - }, "autoload": { "psr-4": { - "PhpOption\\": "src/PhpOption/" + "ParagonIE\\ConstantTime\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Johannes M. Schmitt", - "email": "schmittjoh@gmail.com", - "homepage": "https://github.com/schmittjoh" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" }, { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" } ], - "description": "Option Type for PHP", + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", "keywords": [ - "language", - "option", - "php", - "type" + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" ], "support": { - "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", - "type": "tidelift" - } - ], - "time": "2026-08-24T00:54:40+00:00" + "time": "2025-09-24T15:06:41+00:00" }, { - "name": "psr/clock", - "version": "1.0.0", + "name": "paragonie/random_compat", + "version": "v9.99.100", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "php": ">= 7" }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" - } + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." }, + "type": "library", "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" } ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "csprng", + "polyfill", + "pseudorandom", + "random" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" }, - "time": "2022-11-25T14:36:26+00:00" + "time": "2020-10-15T08:29:30+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "php-http/discovery", + "version": "1.20.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", "shasum": "" }, "require": { - "php": ">=7.4.0" + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" }, - "type": "library", + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" - } + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2707,51 +3448,54 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2024-10-02T11:20:13+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-2.x": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2760,49 +3504,67 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "description": "Standard interfaces for event handling.", + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", "keywords": [ - "events", - "psr", - "psr-14" + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2020-06-27T09:03:43+00:00" }, { - "name": "psr/http-client", - "version": "1.0.3", + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Client\\": "src/" + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2811,50 +3573,60 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { - "source": "https://github.com/php-fig/http-client" + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2023-09-23T14:17:50+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { - "name": "psr/http-factory", - "version": "1.1.0", + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2863,105 +3635,132 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Mike van Riel", + "email": "me@mikevanriel.com" } ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { - "source": "https://github.com/php-fig/http-factory" + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2024-04-15T12:06:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "phpoption/phpoption", + "version": "1.10.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.2.5 || ^8.0" }, - "type": "library", - "extra": { + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.9-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "PhpOption\\": "src/PhpOption/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "Apache-2.0" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "Option Type for PHP", "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" + "language", + "option", + "php", + "type" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, - "time": "2023-04-04T09:54:51+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "phpseclib/phpseclib", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/bb7b959c8159957edae6f5084ebbac765d310e16", + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16", "shasum": "" }, "require": { - "php": ">=8.0.0" + "paragonie/constant_time_encoding": "^2|^3", + "php": ">=8.1", + "symfony/polyfill-php82": "^1.26" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } + "require-dev": { + "brianium/paratest": "^7.22", + "ext-xml": "*", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpunit/phpunit": "^13", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." }, + "type": "library", "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], "psr-4": { - "Psr\\Log\\": "src" + "phpseclib4\\": "phpseclib/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2970,126 +3769,154 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + }, + { + "name": "Jack Worman", + "email": "jack.worman@gmail.com", + "homepage": "https://jackworman.com", + "role": "Developer" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "https://phpseclib.com/", "keywords": [ - "log", - "psr", - "psr-3" + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/4.0.1" }, - "time": "2024-09-11T13:17:53+00:00" + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-08-26T12:15:13+00:00" }, { - "name": "psr/simple-cache", - "version": "3.0.0", + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", "source": { "type": "git", - "url": "https://github.com/php-fig/simple-cache.git", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", - "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": "^7.4 || ^8.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" }, + "type": "library", "autoload": { "psr-4": { - "Psr\\SimpleCache\\": "src/" + "PHPStan\\PhpDocParser\\": [ + "src/" + ] } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for simple caching", - "keywords": [ - "cache", - "caching", - "psr", - "psr-16", - "simple-cache" - ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { - "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" }, - "time": "2021-10-29T13:26:27+00:00" + "time": "2026-08-31T16:05:28+00:00" }, { - "name": "psy/psysh", - "version": "v0.12.24", + "name": "pragmarx/google2fa", + "version": "v9.1.0", "source": { "type": "git", - "url": "https://github.com/bobthecow/psysh.git", - "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", - "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/f00bc788c555adfb6765c437ff3538e59cd88af1", + "reference": "f00bc788c555adfb6765c437ff3538e59cd88af1", "shasum": "" }, "require": { - "ext-json": "*", - "ext-tokenizer": "*", - "nikic/php-parser": "^5.0 || ^4.0", - "php": "^8.0 || ^7.4", - "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" - }, - "conflict": { - "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "composer/class-map-generator": "^1.6" - }, - "suggest": { - "composer/class-map-generator": "Improved tab completion performance with better class discovery.", - "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", - "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + "phpstan/phpstan": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.0|^2.0", + "phpunit/phpunit": "~9|~10|~11|~12|~13", + "psalm/plugin-phpunit": "^0.19|^0.20", + "vimeo/psalm": "^5.26|^6.13" }, - "bin": [ - "bin/psysh" - ], "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": false, - "forward-command": false - }, - "branch-alias": { - "dev-main": "0.12.x-dev" - } - }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Psy\\": "src/" + "PragmaRX\\Google2FA\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3098,72 +3925,107 @@ ], "authors": [ { - "name": "Justin Hileman", - "email": "justin@justinhileman.info" + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" } ], - "description": "An interactive shell for modern PHP.", - "homepage": "https://psysh.org", + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", "keywords": [ - "REPL", - "console", - "interactive", - "shell" + "2fa", + "Authentication", + "MFA", + "Two Factor Authentication", + "google-authenticator", + "google2fa", + "hotp", + "otp", + "rfc4226", + "rfc6238", + "totp" ], "support": { - "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + "docs": "https://github.com/antonioribeiro/google2fa#readme", + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "security": "https://github.com/antonioribeiro/google2fa/security/policy", + "source": "https://github.com/antonioribeiro/google2fa" }, - "time": "2026-06-29T15:41:09+00:00" + "time": "2026-08-15T13:22:01+00:00" }, { - "name": "ramsey/collection", - "version": "2.1.1", + "name": "psr/clock", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/ramsey/collection.git", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", - "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", "shasum": "" }, "require": { - "php": "^8.1" - }, - "require-dev": { - "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.45", - "fakerphp/faker": "^1.24", - "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^2.1", - "mockery/mockery": "^1.6", - "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpspec/prophecy-phpunit": "^2.3", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^10.5", - "ramsey/coding-standard": "^2.3", - "ramsey/conventional-commits": "^1.6", - "roave/security-advisories": "dev-latest" + "php": "^7.0 || ^8.0" }, "type": "library", - "extra": { - "captainhook": { - "force-install": true - }, - "ramsey/conventional-commits": { - "configFile": "conventional-commits.json" + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Ramsey\\Collection\\": "src/" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3172,132 +4034,1304 @@ ], "authors": [ { - "name": "Ben Ramsey", - "email": "ben@benramsey.com", - "homepage": "https://benramsey.com" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "A PHP library for representing and manipulating collections.", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "array", - "collection", - "hash", - "map", - "queue", - "set" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.1.1" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2025-03-22T05:38:12+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "ramsey/uuid", - "version": "4.9.4", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/ramsey/uuid.git", - "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", - "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", - "php": "^8.0", - "ramsey/collection": "^1.2 || ^2.0" + "php": ">=7.2.0" }, - "replace": { - "rhumsaa/uuid": "self.version" + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } }, - "require-dev": { - "captainhook/captainhook": "^5.25", - "captainhook/plugin-composer": "^5.3", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "ergebnis/composer-normalize": "^2.47", - "mockery/mockery": "^1.6", - "paragonie/random-lib": "^2", - "php-mock/php-mock": "^2.6", - "php-mock/php-mock-mockery": "^1.5", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpbench/phpbench": "^1.2.14", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-mockery": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.18", - "squizlabs/php_codesniffer": "^3.13" + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } }, - "suggest": { - "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", - "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", - "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", - "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", - "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { - "captainhook": { - "force-install": true + "branch-alias": { + "dev-master": "1.0.x-dev" } }, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { - "Ramsey\\Uuid\\": "src/" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "guid", - "identifier", - "uuid" + "http", + "http-client", + "psr", + "psr-18" ], "support": { - "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.4" + "source": "https://github.com/php-fig/http-client" }, - "time": "2026-09-16T11:39:30+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { - "name": "symfony/clock", - "version": "v8.1.0", + "name": "psr/http-factory", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/clock.git", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", - "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=8.4.1", - "psr/clock": "^1.0" - }, - "provide": { - "psr/clock-implementation": "1.0" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { - "files": [ - "Resources/now.php" - ], "psr-4": { - "Symfony\\Component\\Clock\\": "" + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.4", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.4" + }, + "time": "2026-09-16T11:39:30+00:00" + }, + { + "name": "spomky-labs/cbor-php", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.0", + "symfony/polyfill-php81": "^1.32" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "Improves the library performance when ext-gmp is missing, and is required to handle the Big Float and Decimal Fraction Tags (4 and 5)", + "ext-gmp": "Strongly recommended when decoding untrusted input: without it, converting the byte string of a Big Number Tag (2 and 3) is quadratic in its length. Also improves the library performance overall" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-15T06:55:29+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T19:02:49+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-13T10:55:57+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:06:25+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3309,23 +5343,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Decouples applications from the system clock", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", - "keywords": [ - "clock", - "psr20", - "time" - ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.1.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" }, "funding": [ { @@ -3345,62 +5374,40 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { - "name": "symfony/console", - "version": "v8.1.7", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", - "reference": "29afb89f4e941f68a6e90f28e3f52ff1f6793a7d", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4.6|^8.0.6" - }, - "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/event-dispatcher": "<8.1" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3408,24 +5415,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Eases the creation of beautiful and testable command line interfaces", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.7" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3445,29 +5454,32 @@ "type": "tidelift" } ], - "time": "2026-09-13T10:55:57+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/css-selector", - "version": "v8.1.6", + "name": "symfony/finder", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/css-selector.git", - "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + "url": "https://github.com/symfony/finder.git", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", - "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", "shasum": "" }, "require": { "php": ">=8.4.1" }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\CssSelector\\": "" + "Symfony\\Component\\Finder\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3482,19 +5494,15 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Converts CSS selectors to XPath expressions", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + "source": "https://github.com/symfony/finder/tree/v8.1.7" }, "funding": [ { @@ -3514,38 +5522,48 @@ "type": "tidelift" } ], - "time": "2026-08-23T10:06:25+00:00" + "time": "2026-09-10T19:14:39+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "name": "symfony/http-foundation", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "url": "https://github.com/symfony/http-foundation.git", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, + "type": "library", "autoload": { - "files": [ - "function.php" + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3554,18 +5572,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" }, "funding": [ { @@ -3585,45 +5603,74 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-14T17:47:24+00:00" }, { - "name": "symfony/error-handler", - "version": "v8.1.5", + "name": "symfony/http-kernel", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/error-handler.git", - "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + "url": "https://github.com/symfony/http-kernel.git", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", - "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", + "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", "shasum": "" }, "require": { "php": ">=8.4.1", "psr/log": "^1|^2|^3", - "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/deprecation-contracts": "<2.5" + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", "symfony/serializer": "^7.4|^8.0", - "symfony/webpack-encore-bundle": "^1.0|^2.0" + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" }, - "bin": [ - "Resources/bin/patch-type-declarations" - ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\ErrorHandler\\": "" + "Symfony\\Component\\HttpKernel\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3643,10 +5690,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to manage errors and ease debugging PHP code", + "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" }, "funding": [ { @@ -3666,50 +5713,44 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-15T07:12:52+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v8.1.5", + "name": "symfony/mailer", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + "url": "https://github.com/symfony/mailer.git", + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10" }, "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", - "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/8783380ecdafa23d36fc90c5873fd44b635c6e10", + "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10", "shasum": "" }, "require": { + "egulias/email-validator": "^2.1.10|^3|^4", "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/event-dispatcher-contracts": "^2.5|^3" + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/security-http": "<7.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/error-handler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Component\\Mailer\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -3729,10 +5770,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" + "source": "https://github.com/symfony/mailer/tree/v8.1.7" }, "funding": [ { @@ -3752,40 +5793,50 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:47:34+00:00" + "time": "2026-09-15T06:01:24+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.7.1", + "name": "symfony/mime", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + "url": "https://github.com/symfony/mime.git", + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", - "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", + "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=8.4.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3793,26 +5844,22 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Allows manipulating MIME messages", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "mime", + "mime-type" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/mime/tree/v8.1.7" }, "funding": [ { @@ -3832,36 +5879,45 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-09-04T11:02:17+00:00" }, { - "name": "symfony/finder", - "version": "v8.1.7", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/4fbe46a3eb64abf8a57f0364075b91f4a233e062", - "reference": "4fbe46a3eb64abf8a57f0364075b91f4a233e062", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=7.2" }, - "require-dev": { - "symfony/filesystem": "^7.4|^8.0" + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3869,18 +5925,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Symfony polyfill for ctype functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], "support": { - "source": "https://github.com/symfony/finder/tree/v8.1.7" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -3900,49 +5962,42 @@ "type": "tidelift" } ], - "time": "2026-09-10T19:14:39+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/http-foundation", - "version": "v8.1.7", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/http-foundation.git", - "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", - "reference": "d8fdc670ed510a69e3a9eb4f5eac89f5ed627e17", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.1" - }, - "conflict": { - "doctrine/dbal": "<4.3" + "php": ">=7.2" }, - "require-dev": { - "doctrine/dbal": "^4.3", - "predis/predis": "^1.1|^2.0", - "symfony/cache": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpFoundation\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3950,18 +6005,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Defines an object-oriented layer for the HTTP specification", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.7" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -3981,78 +6044,43 @@ "type": "tidelift" } ], - "time": "2026-09-14T17:47:24+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { - "name": "symfony/http-kernel", - "version": "v8.1.7", - "source": { - "type": "git", - "url": "https://github.com/symfony/http-kernel.git", - "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", - "reference": "ec7a3a5832c22cf880cf27d2fdc7ad2e94838e85", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/flex": "<2.10", - "symfony/http-client-contracts": "<2.5", - "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", - "symfony/translation-contracts": "<2.5", - "symfony/var-dumper": "<8.1", - "symfony/web-profiler-bundle": "<8.1", - "twig/twig": "<3.21" + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "shasum": "" }, - "require-dev": { - "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^7.4|^8.0", - "symfony/clock": "^7.4|^8.0", - "symfony/config": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/dom-crawler": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/rate-limiter": "^7.4|^8.0", - "symfony/routing": "^7.4|^8.0", - "symfony/serializer": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/translation": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^8.1", - "symfony/var-exporter": "^7.4|^8.0", - "twig/twig": "^3.21|^4.0" + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\HttpKernel\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4060,18 +6088,30 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a structured process for converting a Request into a Response", + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.1.7" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -4091,47 +6131,44 @@ "type": "tidelift" } ], - "time": "2026-09-15T07:12:52+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { - "name": "symfony/mailer", - "version": "v8.1.7", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", "source": { "type": "git", - "url": "https://github.com/symfony/mailer.git", - "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/8783380ecdafa23d36fc90c5873fd44b635c6e10", - "reference": "8783380ecdafa23d36fc90c5873fd44b635c6e10", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { - "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.4.1", - "psr/event-dispatcher": "^1", - "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/service-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/http-client-contracts": "<2.5" + "php": ">=7.2" }, - "require-dev": { - "symfony/console": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/twig-bridge": "^7.4|^8.0" + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Mailer\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4140,18 +6177,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Helps sending emails", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/mailer/tree/v8.1.7" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -4171,50 +6216,46 @@ "type": "tidelift" } ], - "time": "2026-09-15T06:01:24+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { - "name": "symfony/mime", - "version": "v8.1.7", + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/773ac57f20e2795bdadb65b5b1b5f4850d498283", - "reference": "773ac57f20e2795bdadb65b5b1b5f4850d498283", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0" + "ext-iconv": "*", + "php": ">=7.2" }, - "conflict": { - "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1" + "provide": { + "ext-mbstring": "*" }, - "require-dev": { - "egulias/email-validator": "^2.1.10|^3.1|^4", - "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/property-access": "^7.4|^8.0", - "symfony/property-info": "^7.4|^8.0", - "symfony/serializer": "^7.4.17|^8.1.5" + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Mime\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4222,22 +6263,25 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Allows manipulating MIME messages", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ - "mime", - "mime-type" + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.7" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4257,31 +6301,25 @@ "type": "tidelift" } ], - "time": "2026-09-04T11:02:17+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { - "name": "symfony/polyfill-ctype", + "name": "symfony/polyfill-php80", "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { "php": ">=7.2" }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -4294,8 +6332,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4303,24 +6344,28 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "ctype", "polyfill", - "portable" + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4343,25 +6388,22 @@ "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "name": "symfony/polyfill-php81", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { "php": ">=7.2" }, - "suggest": { - "ext-intl": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -4374,8 +6416,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4391,18 +6436,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "grapheme", - "intl", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -4422,28 +6465,24 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { - "name": "symfony/polyfill-intl-idn", - "version": "v1.42.0", + "name": "symfony/polyfill-php82", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", - "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", "shasum": "" }, "require": { - "php": ">=7.2", - "symfony/polyfill-intl-normalizer": "^1.10" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=7.2" }, "type": "library", "extra": { @@ -4457,8 +6496,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - } + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4466,30 +6508,24 @@ ], "authors": [ { - "name": "Laurent Bassin", - "email": "laurent@bassin.info" - }, - { - "name": "Trevor Rowbotham", - "email": "trevor.rowbotham@pm.me" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "idn", - "intl", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" }, "funding": [ { @@ -4509,28 +6545,25 @@ "type": "tidelift" } ], - "time": "2026-08-24T10:51:20+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "name": "symfony/polyfill-php84", + "version": "v1.38.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { "php": ">=7.2" }, - "suggest": { - "ext-intl": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -4543,7 +6576,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, "classmap": [ "Resources/stubs" @@ -4563,18 +6596,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "intl", - "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4594,32 +6625,25 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/polyfill-php85", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { - "ext-iconv": "*", "php": ">=7.2" }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, "type": "library", "extra": { "thanks": { @@ -4632,8 +6656,11 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4649,17 +6676,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -4679,20 +6705,20 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "name": "symfony/polyfill-php86", + "version": "v1.41.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { @@ -4710,7 +6736,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Php86\\": "" }, "classmap": [ "Resources/stubs" @@ -4721,10 +6747,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -4734,7 +6756,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4743,7 +6765,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -4763,25 +6785,31 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-07-02T13:42:24+00:00" }, { - "name": "symfony/polyfill-php82", - "version": "v1.38.1", + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b" + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", - "reference": "002dc0cfe5fd4ed6033d48f27d4f19a486c4b04b", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { "php": ">=7.2" }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, "type": "library", "extra": { "thanks": { @@ -4794,11 +6822,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php82\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Uuid\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4806,24 +6831,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "description": "Symfony polyfill for uuid functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", "polyfill", "portable", - "shim" + "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -4843,41 +6868,32 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:45:58+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "name": "symfony/process", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "url": "https://github.com/symfony/process.git", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Component\\Process\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4886,24 +6902,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -4923,41 +6933,37 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "name": "symfony/property-access", + "version": "v8.1.4", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "url": "https://github.com/symfony/property-access.git", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/property-access/zipball/1a41232c678972b93ce499a504e19ea09dfcd0b2", + "reference": "1a41232c678972b93ce499a504e19ea09dfcd0b2", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4966,24 +6972,29 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/property-access/tree/v8.1.4" }, "funding": [ { @@ -5003,41 +7014,45 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-07-30T12:40:56+00:00" }, { - "name": "symfony/polyfill-php86", - "version": "v1.41.0", + "name": "symfony/property-info", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php86.git", - "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + "url": "https://github.com/symfony/property-info.git", + "reference": "b42ee98197831d33788c33492cfc35ba62d1f701" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", - "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "url": "https://api.github.com/repos/symfony/property-info/zipball/b42ee98197831d33788c33492cfc35ba62d1f701", + "reference": "b42ee98197831d33788c33492cfc35ba62d1f701", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php86\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5046,24 +7061,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + "source": "https://github.com/symfony/property-info/tree/v8.1.7" }, "funding": [ { @@ -5083,45 +7100,49 @@ "type": "tidelift" } ], - "time": "2026-07-02T13:42:24+00:00" + "time": "2026-09-04T10:14:04+00:00" }, { - "name": "symfony/polyfill-uuid", - "version": "v1.37.0", + "name": "symfony/psr-http-message-bridge", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "67fd34de15ded1763aa1e330fe345f080a94022c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", - "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c", + "reference": "67fd34de15ded1763aa1e330fe345f080a94022c", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "provide": { - "ext-uuid": "*" + "php": ">=8.4.1", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" }, - "suggest": { - "ext-uuid": "For best performance" + "conflict": { + "php-http/discovery": "<1.15" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" }, + "type": "symfony-bridge", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Uuid\\": "" - } + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5129,24 +7150,24 @@ ], "authors": [ { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for uuid functions", + "description": "PSR HTTP message bridge", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "uuid" + "http", + "http-message", + "psr-17", + "psr-7" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0" }, "funding": [ { @@ -5166,29 +7187,38 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/process", - "version": "v8.1.7", + "name": "symfony/routing", + "version": "v8.1.6", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" + "url": "https://github.com/symfony/routing.git", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", - "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\Routing\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5208,10 +7238,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], "support": { - "source": "https://github.com/symfony/process/tree/v8.1.7" + "source": "https://github.com/symfony/routing/tree/v8.1.6" }, "funding": [ { @@ -5231,38 +7267,63 @@ "type": "tidelift" } ], - "time": "2026-09-02T12:40:29+00:00" + "time": "2026-08-17T13:18:34+00:00" }, { - "name": "symfony/routing", - "version": "v8.1.6", + "name": "symfony/serializer", + "version": "v8.1.7", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + "url": "https://github.com/symfony/serializer.git", + "reference": "9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", - "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "url": "https://api.github.com/repos/symfony/serializer/zipball/9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321", + "reference": "9a88015b4bb1a2bc5a3bbdf6ca3025444ba67321", "shasum": "" }, "require": { "php": ">=8.4.1", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4.15", + "symfony/type-info": "<7.4" }, "require-dev": { - "psr/log": "^1|^2|^3", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", - "symfony/expression-language": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4.15|~8.0.15|^8.1.2", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\Serializer\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5282,16 +7343,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", - "keywords": [ - "router", - "routing", - "uri", - "url" - ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.1.6" + "source": "https://github.com/symfony/serializer/tree/v8.1.7" }, "funding": [ { @@ -5311,7 +7366,7 @@ "type": "tidelift" } ], - "time": "2026-08-17T13:18:34+00:00" + "time": "2026-09-08T13:39:13+00:00" }, { "name": "symfony/service-contracts", @@ -5597,25 +7652,105 @@ "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, - "require": { - "php": ">=8.1" + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "reference": "ceb48db5b38d6a48640c414be0c69d53980ae5c5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\Translation\\": "" + "Symfony\\Component\\TypeInfo\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5624,26 +7759,28 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to translation", + "description": "Extracts PHP types information.", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "PHPStan", + "phpdoc", + "symfony", + "type" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/type-info/tree/v8.1.5" }, "funding": [ { @@ -5663,7 +7800,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-08-21T17:47:34+00:00" }, { "name": "symfony/uid", @@ -6042,6 +8179,233 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.8.2", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8849e8bf043a2d42d0bec5bda5db2469ad376148", + "reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148", + "shasum": "" + }, + "require": { + "brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "conflict": { + "spomky-labs/cbor-php": "<3.4.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.4" + }, + "suggest": { + "ext-bcmath": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-gmp": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP", + "ext-sodium": "Required by the EdDSA/Ed25519 signature algorithms (-8, -19, -260, -261) and to recompute an OKP public key from its private key", + "spomky-labs/cbor-php": "Required by the RFC 9052 header reader and cryptographic structures. 3.4.0 or later: it ships the six COSE message classes (CBOR\\Tag\\CoseSign1Tag and its siblings) that replace the deprecated Cose\\...Tag classes, and its decoder is what enforces the RFC 9052 label uniqueness and nesting bounds this library relies on" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.8.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-15T17:30:33+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.9", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/727e378fb7a36c26be5c911e4a0120c146741ce7", + "reference": "727e378fb7a36c26be5c911e4a0120c146741ce7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.4", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.8" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.9" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-10T21:10:45+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -6248,74 +8612,26 @@ "license": [ "MIT" ], - "authors": [ - { - "name": "Anton Medvedev", - "email": "anton@medv.io" - } - ], - "description": "Deployment Tool", - "homepage": "https://deployer.org", - "support": { - "docs": "https://deployer.org/docs", - "issues": "https://github.com/deployphp/deployer/issues", - "source": "https://github.com/deployphp/deployer" - }, - "funding": [ - { - "url": "https://github.com/sponsors/antonmedv", - "type": "github" - } - ], - "time": "2025-02-19T16:45:27+00:00" - }, - { - "name": "doctrine/deprecations", - "version": "1.1.6", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "authors": [ + { + "name": "Anton Medvedev", + "email": "anton@medv.io" + } + ], + "description": "Deployment Tool", + "homepage": "https://deployer.org", "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "docs": "https://deployer.org/docs", + "issues": "https://github.com/deployphp/deployer/issues", + "source": "https://github.com/deployphp/deployer" }, - "time": "2026-02-07T07:09:04+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/antonmedv", + "type": "github" + } + ], + "time": "2025-02-19T16:45:27+00:00" }, { "name": "fakerphp/faker", @@ -7793,396 +10109,173 @@ "reference": "3dedf9ea6e2876b5b31f7733d3eacbd86f4653b9" }, "dist": { - "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/3dedf9ea6e2876b5b31f7733d3eacbd86f4653b9", - "reference": "3dedf9ea6e2876b5b31f7733d3eacbd86f4653b9", - "shasum": "" - }, - "require": { - "pestphp/pest-plugin": "^5.0.0", - "php": "^8.4" - }, - "conflict": { - "pestphp/pest": "<5.0.0" - }, - "require-dev": { - "faissaloux/pest-plugin-inside": "^1.11", - "pestphp/pest": "^5.0.0", - "pestphp/pest-dev-tools": "^5.0.0" - }, - "type": "library", - "extra": { - "pest": { - "plugins": [ - "Pest\\Profanity\\Plugin" - ] - } - }, - "autoload": { - "psr-4": { - "Pest\\Profanity\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The Pest Profanity Plugin", - "keywords": [ - "framework", - "pest", - "php", - "plugin", - "profanity", - "test", - "testing", - "unit" - ], - "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v5.0.0" - }, - "time": "2026-07-21T07:48:56+00:00" - }, - { - "name": "phar-io/manifest", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" - }, - { - "name": "phar-io/version", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Library for handling version information and constraints", - "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "6.0.3", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/3dedf9ea6e2876b5b31f7733d3eacbd86f4653b9", + "reference": "3dedf9ea6e2876b5b31f7733d3eacbd86f4653b9", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^2.0", - "phpstan/phpdoc-parser": "^2.0", - "webmozart/assert": "^1.9.1 || ^2" + "pestphp/pest-plugin": "^5.0.0", + "php": "^8.4" + }, + "conflict": { + "pestphp/pest": "<5.0.0" }, "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26", - "shipmonk/dead-code-detector": "^0.5.1" + "faissaloux/pest-plugin-inside": "^1.11", + "pestphp/pest": "^5.0.0", + "pestphp/pest-dev-tools": "^5.0.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.x-dev" + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": "src" + "Pest\\Profanity\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v5.0.0" }, - "time": "2026-03-18T20:49:53+00:00" + "time": "2026-07-21T07:48:56+00:00" }, { - "name": "phpdocumentor/type-resolver", - "version": "2.0.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^4" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev", - "dev-2.x": "2.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2026-01-06T21:53:42+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.3.5", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", - "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2026-08-31T16:05:28+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10001,72 +12094,6 @@ } ], "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.4.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "psalm": { - "pluginClass": "Webmozart\\Assert\\PsalmPlugin" - }, - "branch-alias": { - "dev-master": "2.0-dev", - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" - }, - "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], diff --git a/config/auth.php b/config/auth.php index d7568ff..24d8311 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,6 +42,11 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000..eb7b370 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,178 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + 'passkeys' => 'passkeys', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Passkeys + |-------------------------------------------------------------------------- + | + | These settings configure Fortify's passkey (WebAuthn) support. Passkeys + | allow users to sign in without needing to remember credentials since + | they use public-key cryptography - making them immune to breaches. + | + */ + + 'passkeys' => [ + 'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST), + 'allowed_origins' => [config('app.url')], + 'timeout' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + // No self-registration: accounts are created by an administrator with + // the `accounts:create-user` command. + Features::resetPasswords(), + Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + // 'window' => 0, + ]), + ], + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 0000000..aed4358 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,48 @@ + 'web', + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/config/products.php b/config/products.php new file mode 100644 index 0000000..6207ec0 --- /dev/null +++ b/config/products.php @@ -0,0 +1,50 @@ + [ + + 'salesreport' => [ + 'name' => 'SalesReport', + 'url' => env('CLIENT_SALESREPORT_URL', 'http://localhost:8001'), + ], + + 'productsyncmanager' => [ + 'name' => 'ProductSyncManager', + 'url' => env('CLIENT_PRODUCTSYNCMANAGER_URL', 'http://localhost:8002'), + ], + + 'complianceplatform' => [ + 'name' => 'CompliancePlatform', + 'url' => env('CLIENT_COMPLIANCEPLATFORM_URL', 'http://localhost:8003'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Client Callback Paths + |-------------------------------------------------------------------------- + | + | Appended to each product's base URL. Every product app uses the same + | routes, so these are defined once rather than per client. + | + */ + + 'callback_path' => '/auth/accounts/callback', + + 'post_logout_path' => '/', + +]; diff --git a/database/factories/ClientFactory.php b/database/factories/ClientFactory.php new file mode 100644 index 0000000..5fb82b2 --- /dev/null +++ b/database/factories/ClientFactory.php @@ -0,0 +1,44 @@ + + */ +class ClientFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = Client::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'secret' => 'secret', + 'redirect_uris' => ['http://localhost/callback'], + 'grant_types' => ['authorization_code', 'refresh_token'], + 'first_party' => false, + 'revoked' => false, + ]; + } + + /** + * Indicate that the client is one of our own products. + */ + public function firstParty(): static + { + return $this->state(fn (array $attributes) => ['first_party' => true]); + } +} diff --git a/database/migrations/2026_09_19_105302_add_two_factor_columns_to_users_table.php b/database/migrations/2026_09_19_105302_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..45739ef --- /dev/null +++ b/database/migrations/2026_09_19_105302_add_two_factor_columns_to_users_table.php @@ -0,0 +1,42 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_09_19_105303_create_passkeys_table.php b/database/migrations/2026_09_19_105303_create_passkeys_table.php new file mode 100644 index 0000000..a0b9e7d --- /dev/null +++ b/database/migrations/2026_09_19_105303_create_passkeys_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('credential_id')->unique(); + $table->json('credential'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2026_09_19_105331_create_oauth_auth_codes_table.php b/database/migrations/2026_09_19_105331_create_oauth_auth_codes_table.php new file mode 100644 index 0000000..c700b50 --- /dev/null +++ b/database/migrations/2026_09_19_105331_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105332_create_oauth_access_tokens_table.php b/database/migrations/2026_09_19_105332_create_oauth_access_tokens_table.php new file mode 100644 index 0000000..3e50f7f --- /dev/null +++ b/database/migrations/2026_09_19_105332_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105333_create_oauth_refresh_tokens_table.php b/database/migrations/2026_09_19_105333_create_oauth_refresh_tokens_table.php new file mode 100644 index 0000000..afb3c55 --- /dev/null +++ b/database/migrations/2026_09_19_105333_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105334_create_oauth_clients_table.php b/database/migrations/2026_09_19_105334_create_oauth_clients_table.php new file mode 100644 index 0000000..9794dc8 --- /dev/null +++ b/database/migrations/2026_09_19_105334_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105335_create_oauth_device_codes_table.php b/database/migrations/2026_09_19_105335_create_oauth_device_codes_table.php new file mode 100644 index 0000000..ea07831 --- /dev/null +++ b/database/migrations/2026_09_19_105335_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105347_add_public_id_to_users_table.php b/database/migrations/2026_09_19_105347_add_public_id_to_users_table.php new file mode 100644 index 0000000..b292ead --- /dev/null +++ b/database/migrations/2026_09_19_105347_add_public_id_to_users_table.php @@ -0,0 +1,35 @@ +ulid('public_id')->nullable()->unique()->after('id'); + }); + + User::query()->whereNull('public_id')->eachById(function (User $user): void { + $user->forceFill(['public_id' => (string) Str::ulid()])->saveQuietly(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['public_id']); + $table->dropColumn('public_id'); + }); + } +}; diff --git a/database/migrations/2026_09_19_105348_add_oidc_columns_to_oauth_clients_table.php b/database/migrations/2026_09_19_105348_add_oidc_columns_to_oauth_clients_table.php new file mode 100644 index 0000000..0e65ece --- /dev/null +++ b/database/migrations/2026_09_19_105348_add_oidc_columns_to_oauth_clients_table.php @@ -0,0 +1,37 @@ +boolean('first_party')->default(false)->after('name'); + $table->text('post_logout_redirect_uris')->nullable()->after('redirect_uris'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_clients', function (Blueprint $table) { + $table->dropColumn(['first_party', 'post_logout_redirect_uris']); + }); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105349_add_oidc_columns_to_oauth_auth_codes_table.php b/database/migrations/2026_09_19_105349_add_oidc_columns_to_oauth_auth_codes_table.php new file mode 100644 index 0000000..a7bf4f7 --- /dev/null +++ b/database/migrations/2026_09_19_105349_add_oidc_columns_to_oauth_auth_codes_table.php @@ -0,0 +1,37 @@ +string('nonce')->nullable()->after('scopes'); + $table->dateTime('auth_time')->nullable()->after('nonce'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_auth_codes', function (Blueprint $table) { + $table->dropColumn(['nonce', 'auth_time']); + }); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_09_19_105350_create_client_user_table.php b/database/migrations/2026_09_19_105350_create_client_user_table.php new file mode 100644 index 0000000..2fbf488 --- /dev/null +++ b/database/migrations/2026_09_19_105350_create_client_user_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('client_id')->constrained('oauth_clients')->cascadeOnDelete(); + $table->timestamp('granted_at'); + + $table->unique(['user_id', 'client_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('client_user'); + } +}; diff --git a/database/seeders/ClientSeeder.php b/database/seeders/ClientSeeder.php new file mode 100644 index 0000000..ae0f798 --- /dev/null +++ b/database/seeders/ClientSeeder.php @@ -0,0 +1,68 @@ +seedClient($product['name'], $product['url']); + } + } + + /** + * Create the client for a product if it does not already exist. + */ + protected function seedClient(string $name, string $baseUrl): void + { + $baseUrl = rtrim($baseUrl, '/'); + $redirectUri = $baseUrl.config('products.callback_path'); + $postLogoutUri = $baseUrl.config('products.post_logout_path'); + + $existing = Client::query()->where('name', $name)->first(); + + if ($existing instanceof Client) { + $existing->forceFill([ + 'redirect_uris' => [$redirectUri], + 'post_logout_redirect_uris' => [$postLogoutUri], + 'first_party' => true, + ])->save(); + + $this->command?->line(" {$name} already registered"); + + return; + } + + $client = $this->clients->createAuthorizationCodeGrantClient($name, [$redirectUri]); + + $client->forceFill([ + 'first_party' => true, + 'post_logout_redirect_uris' => [$postLogoutUri], + ])->save(); + + $this->command?->newLine(); + $this->command?->line(" {$name} registered"); + $this->command?->line(" Client ID {$client->id}"); + $this->command?->line(" Client secret {$client->plainSecret}"); + $this->command?->line(' The secret is hashed on save and cannot be shown again.'); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..40819e6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,24 +2,40 @@ namespace Database\Seeders; +use App\Models\Client; use App\Models\User; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - use WithoutModelEvents; - /** * Seed the application's database. + * + * Deliberately without the `WithoutModelEvents` trait: the User model + * assigns its `public_id` — the OIDC subject every product stores — on the + * `creating` event, and silencing model events here would seed accounts + * that no client can identify. */ public function run(): void { - // User::factory(10)->create(); + $this->call(ClientSeeder::class); + + if (! app()->isLocal()) { + return; + } - User::factory()->create([ + $user = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); + + $user->clients()->sync( + Client::query()->pluck('id') + ->mapWithKeys(fn (string $id): array => [$id => ['granted_at' => now()]]) + ->all() + ); + + $this->command?->newLine(); + $this->command?->line(' test@example.com can sign in to every product (password: password)'); } } diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..fbcfdaf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,7 @@ + diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..46d9a8f --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,12 @@ + +

Confirm your password

+

This is a secure area. Please confirm your password to continue.

+ +
+ @csrf + + + + Confirm + +
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..cff2616 --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,20 @@ + +

Reset your password

+

We will email you a link to choose a new one.

+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ @csrf + + + + Email password reset link + + +

+ Back to sign in +

+
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..dd1ff22 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,30 @@ + +

Sign in

+

Use your 3AG Accounts credentials.

+ + @if (session('status')) +
{{ session('status') }}
+ @endif + +
+ @csrf + + + + +
+ + + Forgot password? +
+ + Sign in + + +

+ Accounts are created by an administrator. Contact your account manager if you need access. +

+
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..9b6de48 --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,14 @@ + +

Choose a new password

+ +
+ @csrf + + + + + + + Save new password + +
diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php new file mode 100644 index 0000000..3fa82f1 --- /dev/null +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -0,0 +1,24 @@ + +

Two-factor authentication

+

Enter the code from your authenticator app.

+ +
+ @csrf + + + + Continue + + +
+ Use a recovery code instead + +
+ @csrf + + + + Continue + +
+
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php new file mode 100644 index 0000000..aaaa9b4 --- /dev/null +++ b/resources/views/auth/verify-email.blade.php @@ -0,0 +1,24 @@ + +

Verify your email

+

+ We sent a verification link to your inbox. Open it to finish setting up your account. +

+ + @if (session('status') === 'verification-link-sent') +
+ A fresh verification link is on its way. +
+ @endif + +
+
+ @csrf + Resend verification email +
+ +
+ @csrf + +
+
+
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php new file mode 100644 index 0000000..d026194 --- /dev/null +++ b/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) +
    merge(['class' => 'mt-1.5 space-y-1 text-sm text-red-600']) }}> + @foreach ((array) $messages as $message) +
  • {{ $message }}
  • + @endforeach +
+@endif diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php new file mode 100644 index 0000000..efa7b5d --- /dev/null +++ b/resources/views/components/layouts/app.blade.php @@ -0,0 +1,35 @@ + + + + + + {{ $title ?? '' }}{{ isset($title) ? ' — ' : '' }}{{ config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ + 3 + {{ config('app.name') }} + + + +
+
+ +
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + {{ $slot }} +
+ + diff --git a/resources/views/components/layouts/guest.blade.php b/resources/views/components/layouts/guest.blade.php new file mode 100644 index 0000000..f6c7242 --- /dev/null +++ b/resources/views/components/layouts/guest.blade.php @@ -0,0 +1,25 @@ + + + + + + {{ $title ?? '' }}{{ isset($title) ? ' — ' : '' }}{{ config('app.name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ + 3 + {{ config('app.name') }} + + +
+ {{ $slot }} +
+ + @isset($below) +
{{ $below }}
+ @endisset +
+ + diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php new file mode 100644 index 0000000..74bfaf2 --- /dev/null +++ b/resources/views/components/primary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php new file mode 100644 index 0000000..9bcd199 --- /dev/null +++ b/resources/views/components/text-input.blade.php @@ -0,0 +1,12 @@ +@props(['label', 'name', 'type' => 'text']) + +
+ + merge(['class' => 'mt-1.5 block w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm shadow-sm outline-none focus:border-zinc-900 focus:ring-1 focus:ring-zinc-900']) }} + > + +
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100644 index 0000000..96d0179 --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,31 @@ + +

Your products

+

+ One sign-in covers everything listed here. Signing in to a product sends you back here if your session has expired. +

+ +
+ @forelse ($clients as $client) + @php($homeUrl = $client->homeUrl()) + +
+
+

{{ $client->name }}

+ @if ($homeUrl) +

{{ preg_replace('#^https?://#', '', $homeUrl) }}

+ @endif +
+ + @if ($homeUrl) + + Open + + @endif +
+ @empty +
+

No products have been enabled for this account yet.

+
+ @endforelse +
+
diff --git a/resources/views/oauth/access-denied.blade.php b/resources/views/oauth/access-denied.blade.php new file mode 100644 index 0000000..3ef0d9e --- /dev/null +++ b/resources/views/oauth/access-denied.blade.php @@ -0,0 +1,13 @@ + +

You don't have access to {{ $client->name }}

+

+ Your {{ config('app.name') }} sign-in worked, but this account has not been granted access to + {{ $client->name }}. Ask your account manager to enable it. +

+ + +
diff --git a/resources/views/oauth/authorize.blade.php b/resources/views/oauth/authorize.blade.php new file mode 100644 index 0000000..5f1d6b4 --- /dev/null +++ b/resources/views/oauth/authorize.blade.php @@ -0,0 +1,40 @@ + +

Authorization request

+

+ {{ $client->name }} wants to access your + {{ config('app.name') }} account. +

+ + @if (count($scopes) > 0) +
+

This will let it

+
    + @foreach ($scopes as $scope) +
  • + + {{ $scope->description }} +
  • + @endforeach +
+
+ @endif + +

Signed in as {{ $user->email }}.

+ +
+
+ @csrf + + Allow +
+ +
+ @csrf + @method('DELETE') + + +
+
+
diff --git a/resources/views/settings.blade.php b/resources/views/settings.blade.php new file mode 100644 index 0000000..4407e00 --- /dev/null +++ b/resources/views/settings.blade.php @@ -0,0 +1,69 @@ + +

Settings

+ +
+
+

Profile

+

Your name and email as the products see them.

+ +
+ @csrf + @method('PUT') + + + + +
+ Save +
+ +
+ +
+

Password

+

Changing this changes your sign-in for every product.

+ +
+ @csrf + @method('PUT') + + + + + +
+ Update password +
+ +
+ +
+

Two-factor authentication

+ + @if ($user->hasEnabledTwoFactorAuthentication()) +

Enabled. You are asked for a code every time you sign in.

+ +
+
+ {!! $user->twoFactorQrCodeSvg() !!} +
+
+ +
+ @csrf + @method('DELETE') + +
+ @else +

Add a second step to every sign-in, across every product.

+ +
+ @csrf + Turn on +
+ @endif +
+
+
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 26e294a..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - {{ config('app.name', 'Laravel') }} - - @fonts - - - @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) - @vite(['resources/css/app.css', 'resources/js/app.js']) - @else - - @endif - - -
- @if (Route::has('login')) - - @endif -
-
-
-
-

Let's get started

-

With so many options available to you,
we suggest you start with the following:

- - - -

- v{{ app()->version() }} - - View changelog - - - - -

-
-
- {{-- Laravel Logo --}} - - - - - - - - - - - {{-- 13 --}} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - @if (Route::has('login')) - - @endif - - diff --git a/routes/web.php b/routes/web.php index 86a06c5..f0654dc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,42 @@ name('oidc.discovery'); +Route::get('/oauth/jwks', JwksController::class)->name('oidc.jwks'); +Route::get('/oauth/logout', EndSessionController::class)->middleware('web')->name('oidc.logout'); + +Route::get('/oauth/userinfo', UserInfoController::class) + ->middleware(['auth:api', CheckToken::using('openid')]) + ->name('oidc.userinfo'); + +/* +|-------------------------------------------------------------------------- +| Account Management +|-------------------------------------------------------------------------- +*/ + +Route::redirect('/', '/dashboard')->name('home'); + +Route::middleware(['auth', 'verified'])->group(function (): void { + Route::get('/dashboard', DashboardController::class)->name('dashboard'); + Route::get('/settings', SettingsController::class)->name('settings'); }); diff --git a/tests/Feature/AccountManagementTest.php b/tests/Feature/AccountManagementTest.php new file mode 100644 index 0000000..46b115a --- /dev/null +++ b/tests/Feature/AccountManagementTest.php @@ -0,0 +1,97 @@ +get('/register')->assertNotFound(); + $this->post('/register')->assertNotFound(); +}); + +it('shows the sign-in page', function () { + $this->get('/login') + ->assertOk() + ->assertSee('Sign in') + ->assertSee('Accounts are created by an administrator'); +}); + +it('sends a signed-in user to their dashboard', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->get('/')->assertRedirect('/dashboard'); +}); + +it('lists only the products the user was granted', function () { + $user = User::factory()->create(); + $salesReport = firstPartyClient(); + firstPartyClient('ProductSyncManager', 'http://localhost:8002/auth/accounts/callback'); + + $user->clients()->attach($salesReport, ['granted_at' => now()]); + + $this->actingAs($user)->get('/dashboard') + ->assertOk() + ->assertSee('SalesReport') + ->assertDontSee('ProductSyncManager'); +}); + +it('keeps an unverified user off the dashboard', function () { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user)->get('/dashboard')->assertRedirect('/email/verify'); +}); + +it('creates an account from the console and invites the person to set a password', function () { + Notification::fake(); + + $this->artisan('accounts:create-user', [ + 'email' => 'ada@example.com', + '--name' => 'Ada Lovelace', + ])->assertSuccessful(); + + $user = User::query()->where('email', 'ada@example.com')->firstOrFail(); + + expect($user->name)->toBe('Ada Lovelace') + ->and($user->public_id)->not->toBeEmpty() + ->and($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo($user, ResetPassword::class); + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('grants products while creating an account', function () { + Notification::fake(); + + $client = firstPartyClient(); + + $this->artisan('accounts:create-user', [ + 'email' => 'ada@example.com', + '--name' => 'Ada Lovelace', + '--grant' => ['SalesReport'], + ])->assertSuccessful(); + + $user = User::query()->where('email', 'ada@example.com')->firstOrFail(); + + expect($user->canAccessClient($client))->toBeTrue(); +}); + +it('refuses to create a duplicate account', function () { + Notification::fake(); + + User::factory()->create(['email' => 'ada@example.com']); + + $this->artisan('accounts:create-user', [ + 'email' => 'ada@example.com', + '--name' => 'Ada Lovelace', + ])->assertFailed(); + + expect(User::query()->where('email', 'ada@example.com')->count())->toBe(1); +}); + +it('gives every account an opaque public id', function () { + $user = User::factory()->create(); + + expect($user->public_id)->toBeString()->toHaveLength(26) + ->and($user->public_id)->not->toBe((string) $user->id); +}); diff --git a/tests/Feature/AuthenticationPromptTest.php b/tests/Feature/AuthenticationPromptTest.php new file mode 100644 index 0000000..12038d7 --- /dev/null +++ b/tests/Feature/AuthenticationPromptTest.php @@ -0,0 +1,68 @@ +user = User::factory()->create(); + $this->client = firstPartyClient(); + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + [, $this->challenge] = pkcePair(); +}); + +/** + * Start an authorization request with the given extra parameters. + */ +function promptAuthorize(array $overrides = []): TestResponse +{ + return test()->get('/oauth/authorize?'.http_build_query([ + 'client_id' => test()->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'code_challenge' => test()->challenge, + 'code_challenge_method' => 'S256', + ...$overrides, + ])); +} + +it('answers prompt=none with login_required rather than a login page', function () { + $response = promptAuthorize(['prompt' => 'none']); + + $response->assertRedirectContains('http://localhost:8001/auth/accounts/callback'); + $response->assertRedirectContains('error=login_required'); +}); + +it('answers prompt=none for a signed-in user with a code', function () { + $this->actingAs($this->user); + + $response = promptAuthorize(['prompt' => 'none']); + + expect(authorizationCodeFrom($response))->not->toBeEmpty(); +}); + +it('forces a fresh login when the session is older than max_age', function () { + $this->post(route('login.store'), [ + 'email' => $this->user->email, + 'password' => 'password', + ])->assertRedirect(); + + $this->travel(10)->minutes(); + + promptAuthorize(['max_age' => 60])->assertRedirect(route('login')); + + $this->assertGuest(); +}); + +it('accepts a session that is still within max_age', function () { + $this->post(route('login.store'), [ + 'email' => $this->user->email, + 'password' => 'password', + ])->assertRedirect(); + + $response = promptAuthorize(['max_age' => 3600]); + + expect(authorizationCodeFrom($response))->not->toBeEmpty(); +}); diff --git a/tests/Feature/AuthorizationCodeFlowTest.php b/tests/Feature/AuthorizationCodeFlowTest.php new file mode 100644 index 0000000..8e699eb --- /dev/null +++ b/tests/Feature/AuthorizationCodeFlowTest.php @@ -0,0 +1,190 @@ +user = User::factory()->create(); + $this->client = firstPartyClient(); + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + [$this->verifier, $this->challenge] = pkcePair(); +}); + +/** + * Drive the authorization endpoint the way a relying party would. + */ +function authorize(array $overrides = []): TestResponse +{ + return test()->get('/oauth/authorize?'.http_build_query([ + 'client_id' => test()->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'state' => 'state-value', + 'nonce' => 'nonce-value', + 'code_challenge' => test()->challenge, + 'code_challenge_method' => 'S256', + ...$overrides, + ])); +} + +/** + * Exchange an authorization code for tokens. + */ +function exchange(string $code): TestResponse +{ + return test()->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => test()->client->id, + 'client_secret' => test()->client->plainSecret, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'code_verifier' => test()->verifier, + 'code' => $code, + ]); +} + +it('sends an unauthenticated user to the login page', function () { + authorize()->assertRedirect(route('login')); +}); + +it('skips the consent screen for a first-party client', function () { + $response = $this->actingAs($this->user)->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $this->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'state' => 'state-value', + 'nonce' => 'nonce-value', + 'code_challenge' => $this->challenge, + 'code_challenge_method' => 'S256', + ])); + + $response->assertRedirectContains('http://localhost:8001/auth/accounts/callback'); + $response->assertRedirectContains('state=state-value'); + + expect(authorizationCodeFrom($response))->not->toBeEmpty(); +}); + +it('issues an id token that verifies against the published JWKS', function () { + $this->actingAs($this->user); + + $tokens = exchange(authorizationCodeFrom(authorize()))->assertOk()->json(); + + expect($tokens)->toHaveKeys(['access_token', 'refresh_token', 'id_token', 'expires_in']); + + $key = app(SigningKey::class); + + $configuration = Configuration::forAsymmetricSigner( + new Sha256, + InMemory::plainText($key->privateKey()), + InMemory::plainText($key->publicKey()), + ); + + $verified = $configuration->validator()->validate( + $configuration->parser()->parse($tokens['id_token']), + new SignedWith($configuration->signer(), $configuration->verificationKey()) + ); + + expect($verified)->toBeTrue() + ->and(decodeJwtHeader($tokens['id_token'])['kid'])->toBe($key->keyId()); +}); + +it('puts the expected claims in the id token', function () { + $this->actingAs($this->user); + + $tokens = exchange(authorizationCodeFrom(authorize()))->assertOk()->json(); + + $claims = decodeJwtPayload($tokens['id_token']); + + expect($claims['iss'])->toBe(config('app.url')) + ->and($claims['aud'])->toBe($this->client->id) + ->and($claims['sub'])->toBe($this->user->public_id) + ->and($claims['nonce'])->toBe('nonce-value') + ->and($claims['email'])->toBe($this->user->email) + ->and($claims['email_verified'])->toBeTrue() + ->and($claims['name'])->toBe($this->user->name) + ->and($claims['exp'])->toBeGreaterThan($claims['iat']); +}); + +it('does not leak the primary key as the subject', function () { + $this->actingAs($this->user); + + $claims = decodeJwtPayload(exchange(authorizationCodeFrom(authorize()))->json('id_token')); + + expect($claims['sub'])->not->toBe((string) $this->user->id); +}); + +it('binds the id token to the access token with at_hash', function () { + $this->actingAs($this->user); + + $tokens = exchange(authorizationCodeFrom(authorize()))->assertOk()->json(); + + $expected = rtrim(strtr(base64_encode( + substr(hash('sha256', $tokens['access_token'], true), 0, 16) + ), '+/', '-_'), '='); + + expect(decodeJwtPayload($tokens['id_token'])['at_hash'])->toBe($expected); +}); + +it('reports when the user authenticated', function () { + $this->post(route('login.store'), [ + 'email' => $this->user->email, + 'password' => 'password', + ])->assertRedirect(); + + $claims = decodeJwtPayload(exchange(authorizationCodeFrom(authorize()))->json('id_token')); + + expect($claims['auth_time'])->toBeInt() + ->and($claims['auth_time'])->toBeGreaterThanOrEqual(now()->subMinute()->getTimestamp()); +}); + +it('omits claims the client did not ask for', function () { + $this->actingAs($this->user); + + $claims = decodeJwtPayload( + exchange(authorizationCodeFrom(authorize(['scope' => 'openid'])))->json('id_token') + ); + + expect($claims)->not->toHaveKey('email') + ->and($claims)->not->toHaveKey('name') + ->and($claims['sub'])->toBe($this->user->public_id); +}); + +it('issues no id token when openid was not requested', function () { + $this->actingAs($this->user); + + $tokens = exchange(authorizationCodeFrom(authorize(['scope' => 'profile'])))->assertOk()->json(); + + expect($tokens)->toHaveKey('access_token') + ->and($tokens)->not->toHaveKey('id_token'); +}); + +it('rejects an authorization code replayed with the wrong PKCE verifier', function () { + $this->actingAs($this->user); + + $code = authorizationCodeFrom(authorize()); + + $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'code_verifier' => 'not-the-verifier-we-started-with-0000000000000000', + 'code' => $code, + ])->assertStatus(400); +}); + +it('rejects an authorization code used twice', function () { + $this->actingAs($this->user); + + $code = authorizationCodeFrom(authorize()); + + exchange($code)->assertOk(); + exchange($code)->assertStatus(400); +}); diff --git a/tests/Feature/ClientAccessGateTest.php b/tests/Feature/ClientAccessGateTest.php new file mode 100644 index 0000000..45ee0ea --- /dev/null +++ b/tests/Feature/ClientAccessGateTest.php @@ -0,0 +1,113 @@ +user = User::factory()->create(); + $this->client = firstPartyClient(); + [, $this->challenge] = pkcePair(); +}); + +/** + * Ask for an authorization code for the seeded first-party client. + */ +function requestAuthorization(): TestResponse +{ + return test()->get('/oauth/authorize?'.http_build_query([ + 'client_id' => test()->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'code_challenge' => test()->challenge, + 'code_challenge_method' => 'S256', + ])); +} + +it('refuses a user who has not been granted the product', function () { + $this->actingAs($this->user); + + requestAuthorization() + ->assertForbidden() + ->assertViewIs('oauth.access-denied') + ->assertSee("You don't have access to SalesReport", escape: false); +}); + +it('issues no authorization code to a user without a grant', function () { + $this->actingAs($this->user); + + requestAuthorization()->assertForbidden(); + + expect(Passport::authCode()->newQuery()->count())->toBe(0); +}); + +it('lets a granted user straight through', function () { + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + $this->actingAs($this->user); + + requestAuthorization()->assertRedirectContains('http://localhost:8001/auth/accounts/callback'); +}); + +it('locks the user out again once the grant is revoked', function () { + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + $this->actingAs($this->user); + requestAuthorization()->assertRedirect(); + + $this->artisan('accounts:revoke', [ + 'email' => $this->user->email, + 'client' => 'SalesReport', + ])->assertSuccessful(); + + requestAuthorization()->assertForbidden(); +}); + +it('revokes the tokens a user already holds for the product', function () { + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + Passport::token()->forceFill([ + 'id' => 'token-id', + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'scopes' => ['openid'], + 'revoked' => false, + 'expires_at' => now()->addHour(), + ])->save(); + + $this->artisan('accounts:revoke', [ + 'email' => $this->user->email, + 'client' => 'SalesReport', + ])->assertSuccessful(); + + expect(Passport::token()->newQuery()->find('token-id')->revoked)->toBeTrue(); +}); + +it('grants access from the console', function () { + $this->artisan('accounts:grant', [ + 'email' => $this->user->email, + 'client' => 'SalesReport', + ])->assertSuccessful(); + + expect($this->user->fresh()->canAccessClient($this->client))->toBeTrue(); +}); + +it('does not grant access twice', function () { + foreach (range(1, 2) as $ignored) { + $this->artisan('accounts:grant', [ + 'email' => $this->user->email, + 'client' => 'SalesReport', + ])->assertSuccessful(); + } + + expect($this->user->clients()->count())->toBe(1); +}); + +it('reports an unknown account', function () { + $this->artisan('accounts:grant', [ + 'email' => 'nobody@example.com', + 'client' => 'SalesReport', + ])->assertFailed(); +}); diff --git a/tests/Feature/ConsentScreenTest.php b/tests/Feature/ConsentScreenTest.php new file mode 100644 index 0000000..2034516 --- /dev/null +++ b/tests/Feature/ConsentScreenTest.php @@ -0,0 +1,110 @@ +user = User::factory()->create(); + [$this->verifier, $this->challenge] = pkcePair(); +}); + +/** + * Start an authorization request for the given client. + */ +function authorizeClient(Client $client, string $redirectUri, array $overrides = []): TestResponse +{ + return test()->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'state' => 'state-value', + 'code_challenge' => test()->challenge, + 'code_challenge_method' => 'S256', + ...$overrides, + ])); +} + +it('asks a third-party client for consent', function () { + $client = thirdPartyClient(); + $this->user->clients()->attach($client, ['granted_at' => now()]); + + $response = $this->actingAs($this->user) + ->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => 'https://example.test/callback', + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'state' => 'state-value', + 'code_challenge' => $this->challenge, + 'code_challenge_method' => 'S256', + ])); + + $response->assertOk() + ->assertViewIs('oauth.authorize') + ->assertSee('Some Other App') + ->assertSee('Read your email address'); +}); + +it('issues no code until a third-party client is approved', function () { + $client = thirdPartyClient(); + $this->user->clients()->attach($client, ['granted_at' => now()]); + + $this->actingAs($this->user)->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => 'https://example.test/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'code_challenge' => $this->challenge, + 'code_challenge_method' => 'S256', + ]))->assertOk(); + + expect(Passport::authCode()->newQuery()->count())->toBe(0); +}); + +it('issues a code once a third-party client is approved', function () { + $client = thirdPartyClient(); + $this->user->clients()->attach($client, ['granted_at' => now()]); + + $this->actingAs($this->user)->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => 'https://example.test/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'nonce' => 'nonce-value', + 'code_challenge' => $this->challenge, + 'code_challenge_method' => 'S256', + ]))->assertOk(); + + $response = $this->post('/oauth/authorize', [ + 'auth_token' => session('authToken'), + ]); + + $response->assertRedirectContains('https://example.test/callback'); + + expect(authorizationCodeFrom($response))->not->toBeEmpty(); +}); + +it('keeps the nonce across the consent screen', function () { + $client = thirdPartyClient(); + $this->user->clients()->attach($client, ['granted_at' => now()]); + + $this->actingAs($this->user)->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => 'https://example.test/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'nonce' => 'survives-the-post', + 'code_challenge' => $this->challenge, + 'code_challenge_method' => 'S256', + ]))->assertOk(); + + $this->post('/oauth/authorize', ['auth_token' => session('authToken')])->assertRedirect(); + + expect(Passport::authCode()->newQuery()->first()->nonce)->toBe('survives-the-post'); +}); diff --git a/tests/Feature/EndSessionTest.php b/tests/Feature/EndSessionTest.php new file mode 100644 index 0000000..7acc7fd --- /dev/null +++ b/tests/Feature/EndSessionTest.php @@ -0,0 +1,75 @@ +user = User::factory()->create(); + $this->client = firstPartyClient(); + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + [$verifier, $challenge] = pkcePair(); + + $this->actingAs($this->user); + + $response = $this->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $this->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid', + 'state' => 'state-value', + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ])); + + $this->idToken = $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'code_verifier' => $verifier, + 'code' => authorizationCodeFrom($response), + ])->assertOk()->json('id_token'); +}); + +it('signs the user out', function () { + $this->get('/oauth/logout')->assertRedirect(route('dashboard')); + + $this->assertGuest(); +}); + +it('returns to a redirect uri the client registered', function () { + $this->get('/oauth/logout?'.http_build_query([ + 'id_token_hint' => $this->idToken, + 'post_logout_redirect_uri' => 'http://localhost:8001/', + ]))->assertRedirect('http://localhost:8001/'); +}); + +it('passes the state back to the client', function () { + $this->get('/oauth/logout?'.http_build_query([ + 'id_token_hint' => $this->idToken, + 'post_logout_redirect_uri' => 'http://localhost:8001/', + 'state' => 'round-trip', + ]))->assertRedirect('http://localhost:8001/?state=round-trip'); +}); + +it('refuses a redirect uri the client did not register', function () { + $this->get('/oauth/logout?'.http_build_query([ + 'id_token_hint' => $this->idToken, + 'post_logout_redirect_uri' => 'https://evil.test/steal', + ]))->assertRedirect(route('dashboard')); +}); + +it('refuses a redirect uri with no proof of which client is asking', function () { + $this->get('/oauth/logout?'.http_build_query([ + 'post_logout_redirect_uri' => 'http://localhost:8001/', + ]))->assertRedirect(route('dashboard')); +}); + +it('refuses a forged id token hint', function () { + [$header, $payload] = explode('.', $this->idToken); + + $this->get('/oauth/logout?'.http_build_query([ + 'id_token_hint' => $header.'.'.$payload.'.'.base64_encode('not-a-signature'), + 'post_logout_redirect_uri' => 'http://localhost:8001/', + ]))->assertRedirect(route('dashboard')); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8fdc86b..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,7 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/OpenIdDiscoveryTest.php b/tests/Feature/OpenIdDiscoveryTest.php new file mode 100644 index 0000000..c902454 --- /dev/null +++ b/tests/Feature/OpenIdDiscoveryTest.php @@ -0,0 +1,40 @@ +get('/.well-known/openid-configuration') + ->assertOk() + ->assertJson([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('passport.authorizations.authorize'), + 'token_endpoint' => route('passport.token'), + 'userinfo_endpoint' => route('oidc.userinfo'), + 'jwks_uri' => route('oidc.jwks'), + 'end_session_endpoint' => route('oidc.logout'), + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'code_challenge_methods_supported' => ['S256'], + ]) + ->assertJsonPath('scopes_supported', ['openid', 'profile', 'email']); +}); + +it('publishes the signing key as a JWK', function () { + $response = $this->get('/oauth/jwks')->assertOk(); + + $key = $response->json('keys.0'); + + expect($key) + ->toHaveKeys(['kty', 'use', 'alg', 'kid', 'n', 'e']) + ->and($key['kty'])->toBe('RSA') + ->and($key['alg'])->toBe('RS256') + ->and($key['kid'])->toBe(app(SigningKey::class)->keyId()); +}); + +it('does not expose the private key through the JWKS', function () { + $response = $this->get('/oauth/jwks')->assertOk(); + + expect($response->json('keys.0'))->not->toHaveKey('d'); +}); diff --git a/tests/Feature/RefreshTokenTest.php b/tests/Feature/RefreshTokenTest.php new file mode 100644 index 0000000..dc8f71b --- /dev/null +++ b/tests/Feature/RefreshTokenTest.php @@ -0,0 +1,90 @@ +user = User::factory()->create(); + $this->client = firstPartyClient(); + $this->user->clients()->attach($this->client, ['granted_at' => now()]); + + [$verifier, $challenge] = pkcePair(); + + $this->actingAs($this->user); + + $response = $this->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $this->client->id, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'response_type' => 'code', + 'scope' => 'openid email', + 'state' => 'state-value', + 'nonce' => 'nonce-value', + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ])); + + $this->tokens = $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'redirect_uri' => 'http://localhost:8001/auth/accounts/callback', + 'code_verifier' => $verifier, + 'code' => authorizationCodeFrom($response), + ])->assertOk()->json(); +}); + +it('re-issues an id token through the refresh grant', function () { + $refreshed = $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'refresh_token' => $this->tokens['refresh_token'], + 'scope' => 'openid email', + ])->assertOk()->json(); + + $claims = decodeJwtPayload($refreshed['id_token']); + + expect($claims['sub'])->toBe($this->user->public_id) + ->and($claims['aud'])->toBe($this->client->id) + ->and($claims['email'])->toBe($this->user->email); +}); + +it('does not reuse the original nonce on a refreshed id token', function () { + expect(decodeJwtPayload($this->tokens['id_token'])['nonce'])->toBe('nonce-value'); + + $refreshed = $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'refresh_token' => $this->tokens['refresh_token'], + 'scope' => 'openid', + ])->assertOk()->json(); + + expect(decodeJwtPayload($refreshed['id_token']))->not->toHaveKey('nonce'); +}); + +it('stops refreshing once the product is revoked', function () { + $this->artisan('accounts:revoke', [ + 'email' => $this->user->email, + 'client' => 'SalesReport', + ])->assertSuccessful(); + + $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->client->id, + 'client_secret' => $this->client->plainSecret, + 'refresh_token' => $this->tokens['refresh_token'], + 'scope' => 'openid', + ])->assertStatus(400)->assertJsonPath('error', 'invalid_grant'); +}); + +it('rejects a refresh token presented by another client', function () { + $other = firstPartyClient('ProductSyncManager', 'http://localhost:8002/auth/accounts/callback'); + + $this->post('/oauth/token', [ + 'grant_type' => 'refresh_token', + 'client_id' => $other->id, + 'client_secret' => $other->plainSecret, + 'refresh_token' => $this->tokens['refresh_token'], + 'scope' => 'openid', + ])->assertStatus(400)->assertJsonPath('error', 'invalid_grant'); +}); diff --git a/tests/Feature/UserInfoTest.php b/tests/Feature/UserInfoTest.php new file mode 100644 index 0000000..2a93867 --- /dev/null +++ b/tests/Feature/UserInfoTest.php @@ -0,0 +1,59 @@ +user = User::factory()->create(['name' => 'Ada Lovelace']); +}); + +it('refuses an unauthenticated request', function () { + $this->getJson('/oauth/userinfo')->assertUnauthorized(); +}); + +it('answers a browser-shaped request with 401 rather than a login page', function () { + $this->get('/oauth/userinfo', ['Accept' => 'text/html'])->assertUnauthorized(); +}); + +it('refuses a token without the openid scope', function () { + Passport::actingAs($this->user, ['profile']); + + $this->getJson('/oauth/userinfo')->assertForbidden(); +}); + +it('returns the opaque subject for an openid token', function () { + Passport::actingAs($this->user, ['openid']); + + $this->getJson('/oauth/userinfo') + ->assertOk() + ->assertExactJson(['sub' => $this->user->public_id]); +}); + +it('returns profile claims when the profile scope was granted', function () { + Passport::actingAs($this->user, ['openid', 'profile']); + + $this->getJson('/oauth/userinfo') + ->assertOk() + ->assertJsonPath('name', 'Ada Lovelace') + ->assertJsonMissingPath('email'); +}); + +it('returns email claims when the email scope was granted', function () { + Passport::actingAs($this->user, ['openid', 'email']); + + $this->getJson('/oauth/userinfo') + ->assertOk() + ->assertJsonPath('email', $this->user->email) + ->assertJsonPath('email_verified', true) + ->assertJsonMissingPath('name'); +}); + +it('reports an unverified email as unverified', function () { + $user = User::factory()->unverified()->create(); + + Passport::actingAs($user, ['openid', 'email']); + + $this->getJson('/oauth/userinfo') + ->assertOk() + ->assertJsonPath('email_verified', false); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 2c5012c..c767a93 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,10 @@ extend(TestCase::class) - // ->use(RefreshDatabase::class) + ->use(RefreshDatabase::class) ->in('Feature'); /* @@ -44,7 +48,73 @@ | */ -function something() +/** + * Register a first-party OAuth client, with its plain secret still readable. + */ +function firstPartyClient(string $name = 'SalesReport', string $redirectUri = 'http://localhost:8001/auth/accounts/callback'): Client { - // .. + $client = app(ClientRepository::class)->createAuthorizationCodeGrantClient($name, [$redirectUri]); + + $client->forceFill([ + 'first_party' => true, + 'post_logout_redirect_uris' => ['http://localhost:8001/'], + ])->save(); + + return $client; +} + +/** + * Register a client that is not ours, and so must ask for consent. + */ +function thirdPartyClient(string $name = 'Some Other App', string $redirectUri = 'https://example.test/callback'): Client +{ + return app(ClientRepository::class)->createAuthorizationCodeGrantClient($name, [$redirectUri]); +} + +/** + * Generate a PKCE verifier and its S256 challenge. + * + * @return array{0: string, 1: string} + */ +function pkcePair(): array +{ + $verifier = Str::random(64); + + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + return [$verifier, $challenge]; +} + +/** + * Pull the authorization code out of a redirect back to the client. + */ +function authorizationCodeFrom(TestResponse $response): string +{ + parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); + + return $query['code']; +} + +/** + * Decode a JWT's payload without verifying it. + * + * @return array + */ +function decodeJwtPayload(string $jwt): array +{ + [, $payload] = explode('.', $jwt); + + return json_decode(base64_decode(strtr($payload, '-_', '+/')), true); +} + +/** + * Decode a JWT's header without verifying it. + * + * @return array + */ +function decodeJwtHeader(string $jwt): array +{ + [$header] = explode('.', $jwt); + + return json_decode(base64_decode(strtr($header, '-_', '+/')), true); } diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 44a4f33..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); From 2ec0f1b803c31525519d98833f8cec77796faefb Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sat, 19 Sep 2026 23:25:50 +0600 Subject: [PATCH 2/2] Keep tests/Unit in the repo Deleting the scaffolded example left the directory empty, and git does not track empty directories, so a fresh checkout had no tests/Unit for the suite phpunit.xml declares. CI caught it; local runs did not, because the directory still existed on disk. Co-Authored-By: Claude Opus 5 --- tests/Unit/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/Unit/.gitkeep diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep new file mode 100644 index 0000000..e69de29