From 5c79a54a8fadb6df687d844eca3412ee0d8d6dbf Mon Sep 17 00:00:00 2001 From: Paulo Carvalho Date: Mon, 4 May 2026 13:03:01 +0100 Subject: [PATCH] feat: scaffold oauth authorization server package --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + README.md | 1 + composer.json | 6 + docs/prd/marko-oauth.md | 104 ++++++++ docs/src/content/docs/packages/oauth.md | 233 ++++++++++++++++++ .../framework/tests/RootComposerJsonTest.php | 5 +- packages/oauth/.gitattributes | 5 + packages/oauth/LICENSE | 21 ++ packages/oauth/README.md | 23 ++ packages/oauth/composer.json | 34 +++ packages/oauth/config/oauth.php | 40 +++ packages/oauth/module.php | 27 ++ .../oauth/src/Attributes/RequiresScope.php | 22 ++ packages/oauth/src/Command/KeysCommand.php | 51 ++++ packages/oauth/src/Config/OAuthConfig.php | 102 ++++++++ .../oauth/src/Entity/OAuthAccessToken.php | 40 +++ packages/oauth/src/Entity/OAuthApproval.php | 37 +++ packages/oauth/src/Entity/OAuthAuthCode.php | 46 ++++ packages/oauth/src/Entity/OAuthClient.php | 43 ++++ .../oauth/src/Entity/OAuthRefreshToken.php | 34 +++ packages/oauth/src/Entity/OAuthScope.php | 28 +++ packages/oauth/src/Enum/GrantType.php | 12 + .../oauth/src/Exceptions/OAuthException.php | 52 ++++ .../Repository/OAuthAccessTokenRepository.php | 16 ++ .../OAuthAccessTokenRepositoryInterface.php | 9 + .../Repository/OAuthApprovalRepository.php | 16 ++ .../OAuthApprovalRepositoryInterface.php | 9 + .../Repository/OAuthAuthCodeRepository.php | 16 ++ .../OAuthAuthCodeRepositoryInterface.php | 9 + .../src/Repository/OAuthClientRepository.php | 16 ++ .../OAuthClientRepositoryInterface.php | 9 + .../OAuthRefreshTokenRepository.php | 16 ++ .../OAuthRefreshTokenRepositoryInterface.php | 9 + .../src/Repository/OAuthScopeRepository.php | 16 ++ .../OAuthScopeRepositoryInterface.php | 9 + packages/oauth/src/Service/KeyGenerator.php | 76 ++++++ .../oauth/tests/Command/KeysCommandTest.php | 39 +++ .../oauth/tests/Config/OAuthConfigTest.php | 107 ++++++++ .../oauth/tests/Entity/OAuthEntitiesTest.php | 38 +++ packages/oauth/tests/PackageStructureTest.php | 53 ++++ packages/oauth/tests/Pest.php | 3 + .../Repository/OAuthRepositoriesTest.php | 42 ++++ tests/RepoManagementScriptsTest.php | 85 ++++--- 44 files changed, 1520 insertions(+), 41 deletions(-) create mode 100644 docs/prd/marko-oauth.md create mode 100644 docs/src/content/docs/packages/oauth.md create mode 100644 packages/oauth/.gitattributes create mode 100644 packages/oauth/LICENSE create mode 100644 packages/oauth/README.md create mode 100644 packages/oauth/composer.json create mode 100644 packages/oauth/config/oauth.php create mode 100644 packages/oauth/module.php create mode 100644 packages/oauth/src/Attributes/RequiresScope.php create mode 100644 packages/oauth/src/Command/KeysCommand.php create mode 100644 packages/oauth/src/Config/OAuthConfig.php create mode 100644 packages/oauth/src/Entity/OAuthAccessToken.php create mode 100644 packages/oauth/src/Entity/OAuthApproval.php create mode 100644 packages/oauth/src/Entity/OAuthAuthCode.php create mode 100644 packages/oauth/src/Entity/OAuthClient.php create mode 100644 packages/oauth/src/Entity/OAuthRefreshToken.php create mode 100644 packages/oauth/src/Entity/OAuthScope.php create mode 100644 packages/oauth/src/Enum/GrantType.php create mode 100644 packages/oauth/src/Exceptions/OAuthException.php create mode 100644 packages/oauth/src/Repository/OAuthAccessTokenRepository.php create mode 100644 packages/oauth/src/Repository/OAuthAccessTokenRepositoryInterface.php create mode 100644 packages/oauth/src/Repository/OAuthApprovalRepository.php create mode 100644 packages/oauth/src/Repository/OAuthApprovalRepositoryInterface.php create mode 100644 packages/oauth/src/Repository/OAuthAuthCodeRepository.php create mode 100644 packages/oauth/src/Repository/OAuthAuthCodeRepositoryInterface.php create mode 100644 packages/oauth/src/Repository/OAuthClientRepository.php create mode 100644 packages/oauth/src/Repository/OAuthClientRepositoryInterface.php create mode 100644 packages/oauth/src/Repository/OAuthRefreshTokenRepository.php create mode 100644 packages/oauth/src/Repository/OAuthRefreshTokenRepositoryInterface.php create mode 100644 packages/oauth/src/Repository/OAuthScopeRepository.php create mode 100644 packages/oauth/src/Repository/OAuthScopeRepositoryInterface.php create mode 100644 packages/oauth/src/Service/KeyGenerator.php create mode 100644 packages/oauth/tests/Command/KeysCommandTest.php create mode 100644 packages/oauth/tests/Config/OAuthConfigTest.php create mode 100644 packages/oauth/tests/Entity/OAuthEntitiesTest.php create mode 100644 packages/oauth/tests/PackageStructureTest.php create mode 100644 packages/oauth/tests/Pest.php create mode 100644 packages/oauth/tests/Repository/OAuthRepositoriesTest.php diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0d891c92..8596f810 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -96,6 +96,7 @@ body: - media-imagick - notification - notification-database + - oauth - pagination - pubsub - pubsub-pgsql diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3d16ab77..49f59a5a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -84,6 +84,7 @@ body: - media-imagick - notification - notification-database + - oauth - pagination - pubsub - pubsub-pgsql diff --git a/README.md b/README.md index 501bb503..96a4cf0b 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,7 @@ Marko ships as composable packages — require only what you need. Every package | [authentication](packages/authentication/README.md) | Guard-based authentication | | [authentication-token](packages/authentication-token/README.md) | Token/API authentication | | [authorization](packages/authorization/README.md) | Policy-based authorization | +| [oauth](packages/oauth/README.md) | OAuth2 authorization server | | [hashing](packages/hashing/README.md) | Password hashing | | [security](packages/security/README.md) | Security utilities and middleware | | [cors](packages/cors/README.md) | Cross-Origin Resource Sharing | diff --git a/composer.json b/composer.json index eff56bcc..7de7bf1a 100644 --- a/composer.json +++ b/composer.json @@ -208,6 +208,10 @@ "type": "path", "url": "packages/notification-database" }, + { + "type": "path", + "url": "packages/oauth" + }, { "type": "path", "url": "packages/pagination" @@ -369,6 +373,7 @@ "marko/media-imagick": "self.version", "marko/notification": "self.version", "marko/notification-database": "self.version", + "marko/oauth": "self.version", "marko/pagination": "self.version", "marko/pubsub": "self.version", "marko/pubsub-pgsql": "self.version", @@ -482,6 +487,7 @@ "Marko\\MediaImagick\\Tests\\": "packages/media-imagick/tests/", "Marko\\Notification\\Tests\\": "packages/notification/tests/", "Marko\\Notification\\Database\\Tests\\": "packages/notification-database/tests/", + "Marko\\OAuth\\Tests\\": "packages/oauth/tests/", "Marko\\Pagination\\Tests\\": "packages/pagination/tests/", "Marko\\PubSub\\Tests\\": "packages/pubsub/tests/", "Marko\\PubSub\\PgSql\\Tests\\": "packages/pubsub-pgsql/tests/", diff --git a/docs/prd/marko-oauth.md b/docs/prd/marko-oauth.md new file mode 100644 index 00000000..41cfc0eb --- /dev/null +++ b/docs/prd/marko-oauth.md @@ -0,0 +1,104 @@ +# marko/oauth PRD + +## Problem Statement + +Marko has authentication packages for session and personal access token use cases, but it does not yet provide a native way for an application to act as an OAuth2 authorization server. Developers who need delegated third-party access, authorization-code flows, public clients with PKCE, or machine-to-machine client credentials currently have to assemble protocol handling, storage, routes, keys, consent, scopes, and token validation themselves. + +## Solution + +Build `marko/oauth`, a native Marko package that integrates `league/oauth2-server` with Marko routing, authentication, database, view, CLI, and middleware conventions. The package should feel like a Marko module while delegating protocol-critical OAuth2 behavior to a proven OAuth2 server library. + +The first version should support authorization-code with PKCE, client credentials, refresh tokens with rotation, signed JWT access tokens, database-backed revocation and audit, first-class configured scopes, minimal overridable consent UI, client management commands, and a bearer-token guard/middleware for protected APIs. + +## User Stories + +1. As a Marko application developer, I want to install `marko/oauth`, so that my app can become an OAuth2 authorization server. +2. As a Marko application developer, I want OAuth routes registered automatically, so that installation gives me working protocol endpoints. +3. As a Marko application developer, I want configurable OAuth route prefixes, so that I can fit OAuth endpoints into my app URL structure. +4. As a Marko application developer, I want OAuth protocol routes enabled by default, so that `/oauth/authorize`, `/oauth/token`, and `/oauth/revoke` work after setup. +5. As a Marko application developer, I want management routes disabled by default, so that client administration is not accidentally exposed. +6. As a Marko application developer, I want OAuth data stored with Marko database entities and repositories, so that OAuth fits the framework's persistence model. +7. As a Marko application developer, I want repository interfaces at OAuth storage boundaries, so that I can replace persistence later if necessary. +8. As a Marko application developer, I want an `oauth:keys` command, so that I can generate signing keys without manually using OpenSSL. +9. As a Marko application developer, I want signing keys stored outside the package, so that secrets are not committed or overwritten by package updates. +10. As a Marko application developer, I want configurable key paths and passphrases, so that production deployments can use mounted secrets or environment-provided configuration. +11. As a Marko application developer, I want an `oauth:client:create` command, so that I can create OAuth clients from the CLI. +12. As a Marko application developer, I want `oauth:client:list`, so that I can inspect configured OAuth clients. +13. As a Marko application developer, I want `oauth:client:revoke`, so that compromised or retired clients can be disabled. +14. As a Marko application developer, I want client secrets hashed at rest and shown only once, so that stored client credentials are safer. +15. As a Marko application developer, I want confidential and public client types, so that browser/mobile clients are modeled differently from server-side clients. +16. As a Marko application developer, I want public clients to require PKCE for authorization-code flows, so that public clients do not rely on client secrets. +17. As a Marko application developer, I want confidential clients to use client credentials, so that machine-to-machine integrations are possible. +18. As a third-party app developer, I want to redirect users to the Marko app authorization endpoint, so that users can grant delegated access. +19. As an end user, I want to see a consent screen showing the client and requested scopes, so that I understand what access I am granting. +20. As an end user, I want to deny an authorization request, so that I can refuse access. +21. As an end user, I want previously approved scope sets remembered for a configurable period, so that I am not repeatedly asked for the same consent. +22. As an end user, I want broader scope requests to require renewed consent, so that clients cannot silently escalate access. +23. As an API client, I want to exchange authorization codes for access tokens, so that I can call protected APIs. +24. As an API client, I want to refresh tokens, so that users do not need to re-authorize every time an access token expires. +25. As a security-conscious developer, I want refresh tokens rotated by default, so that token theft is easier to detect and contain. +26. As a security-conscious developer, I want refresh-token reuse detection, so that a reused revoked refresh token can invalidate the token family. +27. As a resource server developer, I want signed JWT access tokens, so that APIs can validate tokens efficiently with a public key. +28. As a resource server developer, I want optional database revocation checks, so that revoked JWT access tokens can be rejected before natural expiry. +29. As a Marko application developer, I want configured scopes with human labels, so that unknown scopes are rejected and consent UI is clear. +30. As a Marko application developer, I want client-level allowed scopes, so that each client can be constrained to approved capabilities. +31. As a Marko application developer, I want a `RequiresScope` attribute, so that route handlers can declare required OAuth scopes. +32. As a Marko application developer, I want OAuth bearer authentication integrated with Marko middleware/guards, so that protected APIs use standard framework patterns. +33. As an API client, I want RFC-style token revocation, so that clients can revoke their own access or refresh tokens. +34. As an application administrator, I want service-level owner revocation APIs, so that user approvals and related refresh tokens can be revoked later. +35. As a package maintainer, I want the package to stay optional outside `marko/framework`, so that core installs do not carry OAuth complexity unnecessarily. + +## Implementation Decisions + +- Build the package as `marko/oauth` with namespace `Marko\OAuth`. +- Keep `marko/authentication-token` as the simple personal access token package; do not merge it with OAuth behavior. +- Wrap `league/oauth2-server` for protocol-critical behavior. +- Use Marko database entities and repositories as the default OAuth storage implementation. +- Provide interfaces where storage, client lookup, token persistence, approvals, and scope resolution cross package boundaries. +- Auto-register OAuth protocol routes with configurable prefix `/oauth`. +- Keep optional management routes disabled by default. +- Ship a minimal consent UI through Marko view conventions and allow app-level template override. +- Generate key pairs with an `oauth:keys` command and store them outside the package under configurable paths. +- Use signed JWT access tokens and persist token identifiers for revocation and audit. +- Support authorization-code with PKCE, client credentials, and refresh token grants in v1. +- Exclude password grant, implicit grant, device code, JWT bearer, SAML bearer, and OpenID Connect from v1. +- Treat scopes as configured capabilities with human-readable labels. +- Reject unknown requested scopes. +- Model clients as either confidential or public. +- Store only hashed client secrets and reveal plaintext only at creation. +- Avoid first-party OAuth shortcuts in v1; use `marko/authentication-token` for simple first-party API tokens. +- Avoid a full admin UI in v1; provide CLI and service primitives. +- Remember consent per user, client, and approved scope set with configurable TTL. +- Rotate refresh tokens by default and detect reuse. +- Support client revocation through `/oauth/revoke` and owner/admin revocation through services. +- Add route-aware middleware support where needed so `RequiresScope` can inspect matched route metadata. + +## Testing Decisions + +- Tests should verify externally visible behavior: issued tokens, redirects, repository persistence, revocation effects, scope enforcement, and CLI output. +- Avoid testing internal implementation details of `league/oauth2-server`. +- Add package structure tests similar to existing Marko packages. +- Add entity/repository tests following existing database-backed package patterns. +- Add command tests following existing CLI command tests. +- Add controller/route tests for OAuth protocol endpoints. +- Add focused grant-flow tests for client credentials, authorization code with PKCE, refresh rotation, and revocation. +- Add scope enforcement tests for `RequiresScope` and bearer-token middleware. +- Add regression tests around key generation overwrite protection and permissions where portable. +- Prior art includes tests in authentication, authentication-token, routing, database, admin-panel, and queue command packages. + +## Out of Scope + +- OpenID Connect. +- Password grant. +- Implicit grant. +- Device code grant. +- JWT bearer and SAML bearer grants. +- A full admin-panel UI for OAuth client management. +- Bundling `marko/oauth` into `marko/framework`. +- Replacing `marko/authentication-token`. +- Token introspection endpoint unless a later multi-service requirement appears. +- First-party OAuth shortcuts that blur OAuth with personal access tokens. + +## Further Notes + +The first implementation slice should scaffold the package, config, module, README, entities, key command, and client commands before implementing the grant flows. Client credentials should be implemented before authorization code because it exercises keys, clients, scopes, token issuance, and persistence without requiring consent UI. diff --git a/docs/src/content/docs/packages/oauth.md b/docs/src/content/docs/packages/oauth.md new file mode 100644 index 00000000..f3e6ba16 --- /dev/null +++ b/docs/src/content/docs/packages/oauth.md @@ -0,0 +1,233 @@ +--- +title: marko/oauth +description: OAuth2 authorization server integration for Marko Framework. +--- + +OAuth2 authorization server integration for Marko Framework. This package provides the foundation for Marko applications that need delegated access and machine-to-machine authentication through OAuth2. It wraps `league/oauth2-server` while keeping the public package surface aligned with Marko modules, configuration, database entities, repositories, CLI commands, and route-level scope declarations. + +This first package slice includes configuration, OAuth storage entities, repository bindings, signing-key generation, grant-type values, and the `#[RequiresScope]` attribute. Protocol controllers and League repository adapters are planned follow-up work on top of this foundation. + +## Installation + +```bash +composer require marko/oauth +``` + +## Configuration + +Configure OAuth in `config/oauth.php`: + +```php title="config/oauth.php" +return [ + 'routes' => [ + 'enabled' => true, + 'prefix' => '/oauth', + 'management' => false, + ], + + 'keys' => [ + 'private' => 'storage/oauth/private.key', + 'public' => 'storage/oauth/public.key', + 'passphrase' => null, + ], + + 'tokens' => [ + 'access_token_ttl' => 'PT1H', + 'refresh_token_ttl' => 'P30D', + 'auth_code_ttl' => 'PT10M', + 'check_revocation' => true, + ], + + 'refresh_tokens' => [ + 'rotate' => true, + 'reuse_detection' => true, + ], + + 'consent' => [ + 'remember' => true, + 'ttl' => 'P1Y', + ], + + 'scopes' => [ + 'profile:read' => 'Read your profile', + 'posts:write' => 'Create and update posts', + ], + + 'default_scopes' => [ + 'profile:read', + ], +]; +``` + +| Key | Purpose | +| --- | --- | +| `routes.enabled` | Enables package-owned OAuth routes when protocol controllers are installed. | +| `routes.prefix` | URL prefix for OAuth routes. Defaults to `/oauth`. | +| `routes.management` | Controls optional client-management routes. Defaults to `false`. | +| `keys.private` | Private signing key path used for token signing. | +| `keys.public` | Public signing key path used for token verification. | +| `keys.passphrase` | Optional private-key passphrase. | +| `tokens.access_token_ttl` | ISO-8601 duration for access token lifetime. | +| `tokens.refresh_token_ttl` | ISO-8601 duration for refresh token lifetime. | +| `tokens.auth_code_ttl` | ISO-8601 duration for authorization code lifetime. | +| `tokens.check_revocation` | Enables database revocation checks for issued access-token identifiers. | +| `refresh_tokens.rotate` | Rotates refresh tokens on use. | +| `refresh_tokens.reuse_detection` | Enables revoked refresh-token reuse detection. | +| `consent.remember` | Remembers consent for the same user, client, and scope set. | +| `consent.ttl` | ISO-8601 duration for remembered consent. | +| `scopes` | Map of configured OAuth scope identifiers to human-readable labels. | +| `default_scopes` | Scopes applied when a request does not specify scopes. | + +## Usage + +### Generate Signing Keys + +Generate the OAuth signing key pair with the CLI: + +```bash +marko oauth:keys +``` + +The command writes the private and public keys to the configured paths. It refuses to overwrite existing key files unless `--force` is passed: + +```bash +marko oauth:keys --force +``` + +The private key is written with restrictive file permissions where the platform supports it. Store production keys outside package files and avoid committing generated key material. + +### Declare Required Scopes + +Use `#[RequiresScope]` to declare the OAuth scopes a route requires: + +```php +use Marko\OAuth\Attributes\RequiresScope; +use Marko\Routing\Attributes\Post; +use Marko\Routing\Http\Response; + +class PostController +{ + #[Post('/posts')] + #[RequiresScope('posts:write')] + public function store(): Response + { + return Response::json(['created' => true], 201); + } +} +``` + +The attribute is repeatable and can be applied to classes or methods. Scope enforcement middleware is planned with the route-aware middleware work described in the package PRD. + +### OAuth Storage Entities + +The package defines these database-backed entities: + +| Entity | Table | Purpose | +| --- | --- | --- | +| `OAuthClient` | `oauth_clients` | Stores OAuth clients, hashed secrets, redirect URIs, allowed scopes, grant types, and revocation metadata. | +| `OAuthAuthCode` | `oauth_auth_codes` | Stores authorization codes, PKCE challenge data, scopes, redirect URI, expiry, and revocation state. | +| `OAuthAccessToken` | `oauth_access_tokens` | Stores issued access-token identifiers for revocation and audit. | +| `OAuthRefreshToken` | `oauth_refresh_tokens` | Stores refresh tokens, token families, expiry, and revocation state. | +| `OAuthApproval` | `oauth_approvals` | Stores remembered user consent per user, client, and scope set. | +| `OAuthScope` | `oauth_scopes` | Stores configured scopes when applications choose database-backed scope management. | + +Each entity has a matching repository interface and concrete Marko database repository bound by `module.php`. + +## Errors + +Following Marko's loud-errors principle, key-generation failures throw `OAuthException` with actionable context and suggestions. + +`OAuthException::keyFileExists()` is thrown when `oauth:keys` would overwrite an existing key without `--force`. + +`OAuthException::keyGenerationFailed()` is thrown when OpenSSL cannot produce a usable RSA key pair. + +`OAuthException::keyDirectoryFailed()` is thrown when the configured key directory cannot be created. + +`OAuthException::keyWriteFailed()` is thrown when generated key material cannot be written to disk. + +## API Reference + +### OAuthConfig + +```php +namespace Marko\OAuth\Config; + +readonly class OAuthConfig +{ + public function routesEnabled(): bool; + public function routePrefix(): string; + public function managementRoutesEnabled(): bool; + public function privateKeyPath(): string; + public function publicKeyPath(): string; + public function keyPassphrase(): ?string; + public function accessTokenTtl(): string; + public function refreshTokenTtl(): string; + public function authCodeTtl(): string; + public function checkRevocation(): bool; + public function rotateRefreshTokens(): bool; + public function detectRefreshTokenReuse(): bool; + public function rememberConsent(): bool; + public function consentTtl(): string; + public function scopes(): array; + public function defaultScopes(): array; +} +``` + +### KeyGenerator + +```php +namespace Marko\OAuth\Service; + +readonly class KeyGenerator +{ + public function generate( + string $privateKeyPath, + string $publicKeyPath, + ?string $passphrase = null, + bool $force = false, + ): void; +} +``` + +### KeysCommand + +```php +namespace Marko\OAuth\Command; + +readonly class KeysCommand implements CommandInterface +{ + public function execute(Input $input, Output $output): int; +} +``` + +### RequiresScope + +```php +namespace Marko\OAuth\Attributes; + +#[RequiresScope('posts:write')] +#[RequiresScope('profile:read', 'posts:read')] +``` + +### Repository Interfaces + +Each repository interface extends `Marko\Database\Repository\RepositoryInterface`: + +```php +namespace Marko\OAuth\Repository; + +interface OAuthClientRepositoryInterface extends RepositoryInterface {} +interface OAuthAuthCodeRepositoryInterface extends RepositoryInterface {} +interface OAuthAccessTokenRepositoryInterface extends RepositoryInterface {} +interface OAuthRefreshTokenRepositoryInterface extends RepositoryInterface {} +interface OAuthApprovalRepositoryInterface extends RepositoryInterface {} +interface OAuthScopeRepositoryInterface extends RepositoryInterface {} +``` + +## Related Packages + +- [marko/authentication](/docs/packages/authentication/) --- Core authentication guards and user-provider contracts. +- [marko/authentication-token](/docs/packages/authentication-token/) --- Personal access tokens for simple first-party API authentication. +- [marko/database](/docs/packages/database/) --- Entity and repository infrastructure used by OAuth storage. +- [marko/routing](/docs/packages/routing/) --- Attribute routing and middleware pipeline integration. +- [marko/view](/docs/packages/view/) --- View abstraction for future consent UI rendering. diff --git a/packages/framework/tests/RootComposerJsonTest.php b/packages/framework/tests/RootComposerJsonTest.php index 52a2d543..22c244b6 100644 --- a/packages/framework/tests/RootComposerJsonTest.php +++ b/packages/framework/tests/RootComposerJsonTest.php @@ -51,6 +51,7 @@ 'marko/media-imagick', 'marko/notification', 'marko/notification-database', + 'marko/oauth', 'marko/pagination', 'marko/pubsub', 'marko/pubsub-pgsql', @@ -77,7 +78,7 @@ 'marko/webhook', ]; -it('adds a require section entry for all 70 marko packages set to self.version', function () use ($rootComposer, $allPackages): void { +it('adds a require section entry for all listed marko packages set to self.version', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('require'); foreach ($allPackages as $package) { @@ -90,7 +91,7 @@ expect($rootComposer)->not->toHaveKey('replace'); }); -it('adds repositories section with path repos for all 70 packages', function () use ($rootComposer, $allPackages): void { +it('adds repositories section with path repos for all listed packages', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('repositories'); $repoUrls = array_column($rootComposer['repositories'], 'url'); diff --git a/packages/oauth/.gitattributes b/packages/oauth/.gitattributes new file mode 100644 index 00000000..e5736f06 --- /dev/null +++ b/packages/oauth/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore diff --git a/packages/oauth/LICENSE b/packages/oauth/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/oauth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/oauth/README.md b/packages/oauth/README.md new file mode 100644 index 00000000..c7481534 --- /dev/null +++ b/packages/oauth/README.md @@ -0,0 +1,23 @@ +# marko/oauth + +OAuth2 authorization server integration for Marko Framework. + +## Installation + +```bash +composer require marko/oauth +``` + +## Quick Example + +Generate signing keys: + +```bash +marko oauth:keys +``` + +The package provides OAuth storage entities, repository bindings, configuration, and key generation as the foundation for OAuth grant flows. + +## Documentation + +Full usage, API reference, and examples: [marko/oauth](https://marko.build/docs/packages/oauth/) diff --git a/packages/oauth/composer.json b/packages/oauth/composer.json new file mode 100644 index 00000000..b49a7143 --- /dev/null +++ b/packages/oauth/composer.json @@ -0,0 +1,34 @@ +{ + "name": "marko/oauth", + "description": "OAuth2 authorization server integration for Marko Framework", + "type": "marko-module", + "license": "MIT", + "require": { + "php": "^8.5", + "ext-openssl": "*", + "league/oauth2-server": "^9.2", + "marko/authentication": "self.version", + "marko/config": "self.version", + "marko/core": "self.version", + "marko/database": "self.version", + "marko/routing": "self.version", + "marko/view": "self.version", + "nyholm/psr7": "^1.8", + "nyholm/psr7-server": "^1.1" + }, + "autoload": { + "psr-4": { + "Marko\\OAuth\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\OAuth\\Tests\\": "tests/" + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/oauth/config/oauth.php b/packages/oauth/config/oauth.php new file mode 100644 index 00000000..904f482b --- /dev/null +++ b/packages/oauth/config/oauth.php @@ -0,0 +1,40 @@ + [ + 'enabled' => true, + 'prefix' => '/oauth', + 'management' => false, + ], + + 'keys' => [ + 'private' => 'storage/oauth/private.key', + 'public' => 'storage/oauth/public.key', + 'passphrase' => null, + ], + + 'tokens' => [ + 'access_token_ttl' => 'PT1H', + 'refresh_token_ttl' => 'P30D', + 'auth_code_ttl' => 'PT10M', + 'check_revocation' => true, + ], + + 'refresh_tokens' => [ + 'rotate' => true, + 'reuse_detection' => true, + ], + + 'consent' => [ + 'remember' => true, + 'ttl' => 'P1Y', + ], + + 'scopes' => [ + ], + + 'default_scopes' => [ + ], +]; diff --git a/packages/oauth/module.php b/packages/oauth/module.php new file mode 100644 index 00000000..9da12f8e --- /dev/null +++ b/packages/oauth/module.php @@ -0,0 +1,27 @@ + [ + OAuthAccessTokenRepositoryInterface::class => OAuthAccessTokenRepository::class, + OAuthApprovalRepositoryInterface::class => OAuthApprovalRepository::class, + OAuthAuthCodeRepositoryInterface::class => OAuthAuthCodeRepository::class, + OAuthClientRepositoryInterface::class => OAuthClientRepository::class, + OAuthRefreshTokenRepositoryInterface::class => OAuthRefreshTokenRepository::class, + OAuthScopeRepositoryInterface::class => OAuthScopeRepository::class, + ], +]; diff --git a/packages/oauth/src/Attributes/RequiresScope.php b/packages/oauth/src/Attributes/RequiresScope.php new file mode 100644 index 00000000..ce333872 --- /dev/null +++ b/packages/oauth/src/Attributes/RequiresScope.php @@ -0,0 +1,22 @@ + + */ + public array $scopes; + + public function __construct( + string ...$scopes, + ) { + $this->scopes = $scopes; + } +} diff --git a/packages/oauth/src/Command/KeysCommand.php b/packages/oauth/src/Command/KeysCommand.php new file mode 100644 index 00000000..fb0588c2 --- /dev/null +++ b/packages/oauth/src/Command/KeysCommand.php @@ -0,0 +1,51 @@ +keys->generate( + privateKeyPath: $this->config->privateKeyPath(), + publicKeyPath: $this->config->publicKeyPath(), + passphrase: $this->config->keyPassphrase(), + force: $input->hasOption('force'), + ); + } catch (OAuthException $exception) { + $output->writeLine("Error: {$exception->getMessage()}"); + + if ($exception->getSuggestion() !== '') { + $output->writeLine($exception->getSuggestion()); + } + + return 1; + } + + $output->writeLine('OAuth signing keys generated.'); + $output->writeLine("Private key: {$this->config->privateKeyPath()}"); + $output->writeLine("Public key: {$this->config->publicKeyPath()}"); + + return 0; + } +} diff --git a/packages/oauth/src/Config/OAuthConfig.php b/packages/oauth/src/Config/OAuthConfig.php new file mode 100644 index 00000000..c1e2ab72 --- /dev/null +++ b/packages/oauth/src/Config/OAuthConfig.php @@ -0,0 +1,102 @@ +config->getBool('oauth.routes.enabled'); + } + + public function routePrefix(): string + { + return rtrim($this->config->getString('oauth.routes.prefix'), '/'); + } + + public function managementRoutesEnabled(): bool + { + return $this->config->getBool('oauth.routes.management'); + } + + public function privateKeyPath(): string + { + return $this->config->getString('oauth.keys.private'); + } + + public function publicKeyPath(): string + { + return $this->config->getString('oauth.keys.public'); + } + + public function keyPassphrase(): ?string + { + $passphrase = $this->config->get('oauth.keys.passphrase'); + + return is_string($passphrase) && $passphrase !== '' ? $passphrase : null; + } + + public function accessTokenTtl(): string + { + return $this->config->getString('oauth.tokens.access_token_ttl'); + } + + public function refreshTokenTtl(): string + { + return $this->config->getString('oauth.tokens.refresh_token_ttl'); + } + + public function authCodeTtl(): string + { + return $this->config->getString('oauth.tokens.auth_code_ttl'); + } + + public function checkRevocation(): bool + { + return $this->config->getBool('oauth.tokens.check_revocation'); + } + + public function rotateRefreshTokens(): bool + { + return $this->config->getBool('oauth.refresh_tokens.rotate'); + } + + public function detectRefreshTokenReuse(): bool + { + return $this->config->getBool('oauth.refresh_tokens.reuse_detection'); + } + + public function rememberConsent(): bool + { + return $this->config->getBool('oauth.consent.remember'); + } + + public function consentTtl(): string + { + return $this->config->getString('oauth.consent.ttl'); + } + + /** + * @return array + */ + public function scopes(): array + { + return $this->config->getArray('oauth.scopes'); + } + + /** + * @return array + */ + public function defaultScopes(): array + { + return $this->config->getArray('oauth.default_scopes'); + } +} diff --git a/packages/oauth/src/Entity/OAuthAccessToken.php b/packages/oauth/src/Entity/OAuthAccessToken.php new file mode 100644 index 00000000..6e07e58f --- /dev/null +++ b/packages/oauth/src/Entity/OAuthAccessToken.php @@ -0,0 +1,40 @@ + + */ +class OAuthAccessTokenRepository extends Repository implements OAuthAccessTokenRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthAccessToken::class; +} diff --git a/packages/oauth/src/Repository/OAuthAccessTokenRepositoryInterface.php b/packages/oauth/src/Repository/OAuthAccessTokenRepositoryInterface.php new file mode 100644 index 00000000..d1b3a428 --- /dev/null +++ b/packages/oauth/src/Repository/OAuthAccessTokenRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +class OAuthApprovalRepository extends Repository implements OAuthApprovalRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthApproval::class; +} diff --git a/packages/oauth/src/Repository/OAuthApprovalRepositoryInterface.php b/packages/oauth/src/Repository/OAuthApprovalRepositoryInterface.php new file mode 100644 index 00000000..4d42e36f --- /dev/null +++ b/packages/oauth/src/Repository/OAuthApprovalRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +class OAuthAuthCodeRepository extends Repository implements OAuthAuthCodeRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthAuthCode::class; +} diff --git a/packages/oauth/src/Repository/OAuthAuthCodeRepositoryInterface.php b/packages/oauth/src/Repository/OAuthAuthCodeRepositoryInterface.php new file mode 100644 index 00000000..f5732cd6 --- /dev/null +++ b/packages/oauth/src/Repository/OAuthAuthCodeRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +class OAuthClientRepository extends Repository implements OAuthClientRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthClient::class; +} diff --git a/packages/oauth/src/Repository/OAuthClientRepositoryInterface.php b/packages/oauth/src/Repository/OAuthClientRepositoryInterface.php new file mode 100644 index 00000000..224b74da --- /dev/null +++ b/packages/oauth/src/Repository/OAuthClientRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +class OAuthRefreshTokenRepository extends Repository implements OAuthRefreshTokenRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthRefreshToken::class; +} diff --git a/packages/oauth/src/Repository/OAuthRefreshTokenRepositoryInterface.php b/packages/oauth/src/Repository/OAuthRefreshTokenRepositoryInterface.php new file mode 100644 index 00000000..7704ab6c --- /dev/null +++ b/packages/oauth/src/Repository/OAuthRefreshTokenRepositoryInterface.php @@ -0,0 +1,9 @@ + + */ +class OAuthScopeRepository extends Repository implements OAuthScopeRepositoryInterface +{ + protected const string ENTITY_CLASS = OAuthScope::class; +} diff --git a/packages/oauth/src/Repository/OAuthScopeRepositoryInterface.php b/packages/oauth/src/Repository/OAuthScopeRepositoryInterface.php new file mode 100644 index 00000000..55ebab6f --- /dev/null +++ b/packages/oauth/src/Repository/OAuthScopeRepositoryInterface.php @@ -0,0 +1,9 @@ + 4096, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if (!$key instanceof OpenSSLAsymmetricKey) { + throw OAuthException::keyGenerationFailed(); + } + + $privateKey = ''; + if (!openssl_pkey_export($key, $privateKey, $passphrase)) { + throw OAuthException::keyGenerationFailed(); + } + + $details = openssl_pkey_get_details($key); + $publicKey = is_array($details) ? ($details['key'] ?? null) : null; + + if (!is_string($publicKey) || $publicKey === '') { + throw OAuthException::keyGenerationFailed(); + } + + $this->ensureDirectory($privateKeyPath); + $this->ensureDirectory($publicKeyPath); + + if (file_put_contents($privateKeyPath, $privateKey) === false) { + throw OAuthException::keyWriteFailed($privateKeyPath); + } + + if (file_put_contents($publicKeyPath, $publicKey) === false) { + throw OAuthException::keyWriteFailed($publicKeyPath); + } + + @chmod($privateKeyPath, 0600); + @chmod($publicKeyPath, 0644); + } + + private function ensureDirectory( + string $path, + ): void { + $directory = dirname($path); + + if (!is_dir($directory)) { + if (!mkdir($directory, 0700, true) && !is_dir($directory)) { + throw OAuthException::keyDirectoryFailed($directory); + } + } + } +} diff --git a/packages/oauth/tests/Command/KeysCommandTest.php b/packages/oauth/tests/Command/KeysCommandTest.php new file mode 100644 index 00000000..c0dedb8c --- /dev/null +++ b/packages/oauth/tests/Command/KeysCommandTest.php @@ -0,0 +1,39 @@ +generate($private, $public); + + expect(file_exists($private))->toBeTrue() + ->and(file_exists($public))->toBeTrue() + ->and(file_get_contents($private))->toContain('PRIVATE KEY') + ->and(file_get_contents($public))->toContain('PUBLIC KEY'); + + unlink($private); + unlink($public); + rmdir($directory); +}); + +it('refuses to overwrite keys without force', function (): void { + $directory = sys_get_temp_dir() . '/marko-oauth-' . bin2hex(random_bytes(8)); + mkdir($directory, 0700, true); + $private = $directory . '/private.key'; + $public = $directory . '/public.key'; + file_put_contents($private, 'existing'); + file_put_contents($public, 'existing'); + + expect(fn () => (new KeyGenerator())->generate($private, $public)) + ->toThrow(OAuthException::class, 'OAuth key file already exists.'); + + unlink($private); + unlink($public); + rmdir($directory); +}); diff --git a/packages/oauth/tests/Config/OAuthConfigTest.php b/packages/oauth/tests/Config/OAuthConfigTest.php new file mode 100644 index 00000000..f512e37f --- /dev/null +++ b/packages/oauth/tests/Config/OAuthConfigTest.php @@ -0,0 +1,107 @@ +values; + + foreach ($segments as $segment) { + $value = $value[$segment]; + } + + return $value; + } + + public function has( + string $key, + ?string $scope = null, + ): bool { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string { + return (string) $this->get($key, $scope); + } + + public function getInt( + string $key, + ?string $scope = null, + ): int { + return (int) $this->get($key, $scope); + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool { + return (bool) $this->get($key, $scope); + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float { + return (float) $this->get($key, $scope); + } + + public function getArray( + string $key, + ?string $scope = null, + ): array { + return $this->get($key, $scope); + } + + public function all(?string $scope = null): array + { + return $this->values; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; + + return new OAuthConfig($repository); +} + +it('reads oauth configuration values', function (): void { + $config = oauthConfig([ + 'oauth' => require dirname(__DIR__, 2) . '/config/oauth.php', + ]); + + expect($config->routesEnabled())->toBeTrue() + ->and($config->routePrefix())->toBe('/oauth') + ->and($config->managementRoutesEnabled())->toBeFalse() + ->and($config->privateKeyPath())->toBe('storage/oauth/private.key') + ->and($config->publicKeyPath())->toBe('storage/oauth/public.key') + ->and($config->keyPassphrase())->toBeNull() + ->and($config->accessTokenTtl())->toBe('PT1H') + ->and($config->refreshTokenTtl())->toBe('P30D') + ->and($config->authCodeTtl())->toBe('PT10M') + ->and($config->checkRevocation())->toBeTrue() + ->and($config->rotateRefreshTokens())->toBeTrue() + ->and($config->detectRefreshTokenReuse())->toBeTrue() + ->and($config->rememberConsent())->toBeTrue() + ->and($config->consentTtl())->toBe('P1Y') + ->and($config->scopes())->toBe([]) + ->and($config->defaultScopes())->toBe([]); +}); diff --git a/packages/oauth/tests/Entity/OAuthEntitiesTest.php b/packages/oauth/tests/Entity/OAuthEntitiesTest.php new file mode 100644 index 00000000..e03b8234 --- /dev/null +++ b/packages/oauth/tests/Entity/OAuthEntitiesTest.php @@ -0,0 +1,38 @@ +getAttributes(Table::class); + + expect($attributes)->toHaveCount(1) + ->and($attributes[0]->newInstance()->name)->toBe($table); +})->with([ + [OAuthClient::class, 'oauth_clients'], + [OAuthAuthCode::class, 'oauth_auth_codes'], + [OAuthAccessToken::class, 'oauth_access_tokens'], + [OAuthRefreshToken::class, 'oauth_refresh_tokens'], + [OAuthApproval::class, 'oauth_approvals'], + [OAuthScope::class, 'oauth_scopes'], +]); + +it('models confidential clients with hashed secrets', function (): void { + $client = new OAuthClient(); + $client->id = 'client-id'; + $client->name = 'Example App'; + $client->secretHash = password_hash('secret', PASSWORD_BCRYPT); + $client->confidential = true; + + expect($client->id)->toBe('client-id') + ->and($client->confidential)->toBeTrue() + ->and(password_verify('secret', (string) $client->secretHash))->toBeTrue(); +}); diff --git a/packages/oauth/tests/PackageStructureTest.php b/packages/oauth/tests/PackageStructureTest.php new file mode 100644 index 00000000..544dd6ff --- /dev/null +++ b/packages/oauth/tests/PackageStructureTest.php @@ -0,0 +1,53 @@ +toBeArray() + ->and($composer['name'])->toBe('marko/oauth') + ->and($composer['type'])->toBe('marko-module') + ->and($composer['license'])->toBe('MIT') + ->and($composer['require'])->toHaveKey('league/oauth2-server') + ->and($composer['require'])->toHaveKey('marko/authentication') + ->and($composer['require'])->toHaveKey('marko/database') + ->and($composer['extra']['marko']['module'])->toBeTrue() + ->and($composer['autoload']['psr-4'])->toHaveKey('Marko\\OAuth\\') + ->and($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\OAuth\\Tests\\'); +}); + +it('has package distribution metadata', function (): void { + $root = dirname(__DIR__); + + expect(file_exists($root . '/LICENSE'))->toBeTrue() + ->and(file_get_contents($root . '/LICENSE'))->toContain('MIT License') + ->and(file_exists($root . '/.gitattributes'))->toBeTrue() + ->and(file_get_contents($root . '/.gitattributes'))->toContain('/tests') + ->and(file_get_contents($root . '/.gitattributes'))->toContain('export-ignore'); +}); + +it('provides module configuration', function (): void { + $module = require dirname(__DIR__) . '/module.php'; + + expect($module)->toBeArray() + ->and($module)->toHaveKey('bindings') + ->and($module['bindings'])->toBeArray(); +}); + +it('provides default oauth configuration', function (): void { + $config = require dirname(__DIR__) . '/config/oauth.php'; + + expect($config)->toHaveKeys([ + 'routes', + 'keys', + 'tokens', + 'refresh_tokens', + 'consent', + 'scopes', + 'default_scopes', + ]) + ->and($config['routes']['prefix'])->toBe('/oauth') + ->and($config['routes']['enabled'])->toBeTrue() + ->and($config['routes']['management'])->toBeFalse(); +}); diff --git a/packages/oauth/tests/Pest.php b/packages/oauth/tests/Pest.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/packages/oauth/tests/Pest.php @@ -0,0 +1,3 @@ +toBeTrue() + ->and(in_array(RepositoryInterface::class, class_implements($interface), true))->toBeTrue(); +})->with([ + OAuthClientRepositoryInterface::class, + OAuthAuthCodeRepositoryInterface::class, + OAuthAccessTokenRepositoryInterface::class, + OAuthRefreshTokenRepositoryInterface::class, + OAuthApprovalRepositoryInterface::class, + OAuthScopeRepositoryInterface::class, +]); + +it('binds repository interfaces to concrete repositories', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['bindings'][OAuthClientRepositoryInterface::class])->toBe(OAuthClientRepository::class) + ->and($module['bindings'][OAuthAuthCodeRepositoryInterface::class])->toBe(OAuthAuthCodeRepository::class) + ->and($module['bindings'][OAuthAccessTokenRepositoryInterface::class])->toBe(OAuthAccessTokenRepository::class) + ->and($module['bindings'][OAuthRefreshTokenRepositoryInterface::class])->toBe( + OAuthRefreshTokenRepository::class, + ) + ->and($module['bindings'][OAuthApprovalRepositoryInterface::class])->toBe(OAuthApprovalRepository::class) + ->and($module['bindings'][OAuthScopeRepositoryInterface::class])->toBe(OAuthScopeRepository::class); +}); diff --git a/tests/RepoManagementScriptsTest.php b/tests/RepoManagementScriptsTest.php index 3822e737..d88eaf5d 100644 --- a/tests/RepoManagementScriptsTest.php +++ b/tests/RepoManagementScriptsTest.php @@ -4,19 +4,22 @@ $binDir = dirname(__DIR__) . '/bin'; -it('creates bin/create-split-repos.sh that bulk-creates all 70 split repos on GitHub', function () use ($binDir): void { - $path = $binDir . '/create-split-repos.sh'; - - expect(file_exists($path))->toBeTrue('bin/create-split-repos.sh does not exist'); - - $content = file_get_contents($path); - - expect($content) - ->toContain('#!/usr/bin/env bash') - ->toContain('GITHUB_ORG=') - ->toContain('gh repo create') - ->toContain('packages/'); -}); +it( + 'creates bin/create-split-repos.sh that bulk-creates all 70 split repos on GitHub', + function () use ($binDir): void { + $path = $binDir . '/create-split-repos.sh'; + + expect(file_exists($path))->toBeTrue('bin/create-split-repos.sh does not exist'); + + $content = file_get_contents($path); + + expect($content) + ->toContain('#!/usr/bin/env bash') + ->toContain('GITHUB_ORG=') + ->toContain('gh repo create') + ->toContain('packages/'); + } +); it('creates bin/register-packagist.sh that registers all 70 packages on Packagist', function () use ($binDir): void { $path = $binDir . '/register-packagist.sh'; @@ -63,15 +66,16 @@ ->toContain('for pkg_dir in'); }); -it('skips repos that already exist without erroring', function () use ($binDir): void { +it('detects existing repos without erroring', function () use ($binDir): void { $createRepos = file_get_contents($binDir . '/create-split-repos.sh'); $registerPackagist = file_get_contents($binDir . '/register-packagist.sh'); $addPackage = file_get_contents($binDir . '/add-package.sh'); - // create-split-repos.sh checks if repo exists before creating + // create-split-repos.sh checks existing repos before creating expect($createRepos) - ->toContain('gh repo view') - ->toContain('already exists, skipping') + ->toContain('gh repo list') + ->toContain('EXISTING_REPOS') + ->toContain('already existed') // register-packagist.sh handles HTTP 400 (already registered) gracefully ->and($registerPackagist) ->toContain('"400"') @@ -82,30 +86,33 @@ ->toContain('already exists'); }); -it('validates required tools and credentials before running (gh, curl, jq, API tokens)', function () use ($binDir): void { - $createRepos = file_get_contents($binDir . '/create-split-repos.sh'); - $registerPackagist = file_get_contents($binDir . '/register-packagist.sh'); - $addPackage = file_get_contents($binDir . '/add-package.sh'); - - // create-split-repos.sh validates gh, jq, and gh auth +it( + 'validates required tools and credentials before running (gh, curl, jq, API tokens)', + function () use ($binDir): void { + $createRepos = file_get_contents($binDir . '/create-split-repos.sh'); + $registerPackagist = file_get_contents($binDir . '/register-packagist.sh'); + $addPackage = file_get_contents($binDir . '/add-package.sh'); + + // create-split-repos.sh validates gh, jq, and gh auth expect($createRepos) - ->toContain('command -v gh') - ->toContain('command -v jq') - ->toContain('gh auth status') - // register-packagist.sh validates curl, jq, and requires API tokens + ->toContain('command -v gh') + ->toContain('command -v jq') + ->toContain('gh auth status') + // register-packagist.sh validates curl, jq, and requires API tokens ->and($registerPackagist) - ->toContain('command -v curl') - ->toContain('command -v jq') - ->toContain('PACKAGIST_USERNAME:?') - ->toContain('PACKAGIST_TOKEN:?') - // add-package.sh validates gh, jq, curl, and requires API tokens + ->toContain('command -v curl') + ->toContain('command -v jq') + ->toContain('PACKAGIST_USERNAME:?') + ->toContain('PACKAGIST_TOKEN:?') + // add-package.sh validates gh, jq, curl, and requires API tokens ->and($addPackage) - ->toContain('command -v gh') - ->toContain('command -v jq') - ->toContain('command -v curl') - ->toContain('PACKAGIST_USERNAME:?') - ->toContain('PACKAGIST_TOKEN:?'); -}); + ->toContain('command -v gh') + ->toContain('command -v jq') + ->toContain('command -v curl') + ->toContain('PACKAGIST_USERNAME:?') + ->toContain('PACKAGIST_TOKEN:?'); + } +); it('makes all scripts executable', function () use ($binDir): void { $scripts = [ @@ -115,6 +122,6 @@ ]; foreach ($scripts as $script) { - expect(is_executable($script))->toBeTrue("Script not executable: {$script}"); + expect(is_executable($script))->toBeTrue("Script not executable: $script"); } });