diff --git a/RELEASE.md b/RELEASE.md index 09676734..76121464 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,26 @@ -# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, and safer upload names +# v1.6.74 — Webhook endpoints for API keys, signed per endpoint, safer upload names, App Review access, and Sign in with Fleetbase ## Added - **Webhook endpoints can be managed with an API key** (#319). `/v1/webhook-endpoints` supports create, list (optionally filtered by `url`), show, update, `POST {id}/rotate-secret` and delete. Endpoints are scoped to the key's company and environment (`live`/`test`). The URL must pass `PublicWebhookUrl`, and events are checked against `config('api.events')` when it is set. Integrations such as WooCommerce and Shopify connectors can now register their own endpoints instead of merchants copying URLs into the console. - **Per-endpoint signing secrets** (#319). Each endpoint has its own secret (`whsec_…`), stored encrypted, hidden from serialisation and kept out of the activity log. Endpoints that already exist get one the first time it is needed. Deliveries now also carry `X-Fleetbase-Signature: t=,v1=.", secret)>`; `EndpointSignature::verify()` documents the receiver side. The legacy `Signature` header is unchanged. The console can reveal (`GET int/v1/webhook-endpoints/{id}/secret`, view permission) and rotate (`POST int/v1/webhook-endpoints/{id}/rotate-secret`, create permission) the secret. +- **App Review access** (#323). Apple and Google reviewers can't receive a texted sign-in code. Extensions register what their app signs reviewers in to (`Fleetbase\Support\AppReview::register()`, e.g. a Storefront store or an organization's Navigator app) with a review number from the 555-0100–0199 range reserved for fiction. Owners turn access on in the Console (`GET|PUT int/v1/review-access/{type}/{id}`) and give the app store that number and a six-digit code, stored hashed, shown once, and accepted only for that subject. Works on Fleetbase Cloud and self-hosted. `AppReview::configuredAccountMatches()` is the shared env fallback for self-hosted review accounts. +- **Sign in with Fleetbase** (OAuth 2.0 / OpenID Connect provider). Registered apps can offer "Continue with Fleetbase" and, with the user's consent, act on one organization they administer. Built on league/oauth2-server. + - Authorization code flow with PKCE (`S256`) required for every client, rotating refresh tokens, RFC 7009 revocation, ID tokens, `/oauth/userinfo`, `/oauth/jwks` and `/.well-known/openid-configuration`. + - The consent screen runs in the console, so the console's sign-in and two-factor rules apply. Endpoints: `int/v1/oauth-server/authorization` (GET, POST, DELETE). + - Each consent is an `oauth_grants` row bound to the organization the user picked. Every refresh and every API call checks that the user is still active and still administers that organization. + - Scopes: `openid`, `profile`, `email` and `organization:read` (`GET /oauth/v1/organization`). Extensions register their own scopes with `Fleetbase\Auth\OAuthServer\OAuthScopes::register()`, optionally covering App Review types served by `/oauth/v1/review-access/{type}/{id}`. + - Replayed codes revoke their tokens; a replayed refresh token revokes the whole grant. + - System admins register apps, rotate secrets, turn apps off and revoke grants (`int/v1/oauth-server/apps`). Users list and remove their connected apps (`int/v1/oauth-server/connected-apps`) and get an email when they authorize a new one. + - Artisan: `oauth-server:keys`, `oauth-server:client create|rotate|list`, `oauth-server:prune` (scheduled daily). ## Security +- **Numbers reserved for fiction are never texted** (#323). `SmsService::send()` answers as sent without calling a provider, and SMS verification codes for those numbers are saved but not sent. + - **Uploaded files are stored under an extension that matches their contents** (#320). The stored name used to keep the client's extension, so a file that passed content-type validation could be stored as `.php`. The client's extension is now kept only when it matches the detected type (or is a loosely detected data format such as `csv`, `json` or `xlsx`); otherwise the extension comes from the detected type. Script extensions (`php`, `phtml`, `phar`, `shtml`, `htaccess`, `cgi`, …) are never used and become `bin`. `original_filename` is unchanged. ## Upgrade Steps -- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column. +- Run migrations: `php artisan migrate` adds the nullable `webhook_endpoints.secret` column, the `review_accesses` table and the `oauth_*` provider tables (`oauth_clients`, `oauth_grants`, `oauth_auth_codes`, `oauth_access_tokens`, `oauth_refresh_tokens`). +- To use Sign in with Fleetbase, give every API instance the same signing key pair: `php artisan oauth-server:keys` (key files in `storage/`), or `php artisan oauth-server:keys --show` and set `OAUTH_SERVER_PRIVATE_KEY` / `OAUTH_SERVER_PUBLIC_KEY`. Set `OAUTH_SERVER_ISSUER` if the public API URL differs from `APP_URL`. New dependencies: `league/oauth2-server`, `nyholm/psr7`, `symfony/psr-http-message-bridge`. diff --git a/composer.json b/composer.json index a802c6ca..772a1b70 100644 --- a/composer.json +++ b/composer.json @@ -45,9 +45,11 @@ "laravel/socialite": "^5.31", "lcobucci/clock": "3.3.1", "lcobucci/jwt": "^5.4", + "league/oauth2-server": "^8.5.5", "maatwebsite/excel": "^3.1", "milon/barcode": "^10.0", "mossadal/math-parser": "^1.3", + "nyholm/psr7": "^1.5", "phpoffice/phpspreadsheet": "^1.28", "phrity/websocket": "^1.7", "pragmarx/google2fa": "^8.0", @@ -61,6 +63,7 @@ "spatie/laravel-schedule-monitor": "^3.7", "spatie/laravel-sluggable": "^3.5", "sqids/sqids": "^0.4.1", + "symfony/psr-http-message-bridge": "^2.1|^6.0|^7.0", "xantios/mimey": "^2.2.0" }, "require-dev": { diff --git a/config/oauth-server.php b/config/oauth-server.php new file mode 100644 index 00000000..c27b42d9 --- /dev/null +++ b/config/oauth-server.php @@ -0,0 +1,50 @@ + env('OAUTH_SERVER_ENABLED', true), + + /* + * The issuer identifier (`iss`), and the base of every endpoint in the discovery document. + * Defaults to APP_URL, e.g. https://api.fleetbase.io. + */ + 'issuer' => env('OAUTH_SERVER_ISSUER'), + + /* + * PEM key pair for signing access tokens and ID tokens. When unset, the keys are read from + * storage/oauth-private.key and storage/oauth-public.key (`php artisan oauth-server:keys`). + */ + 'private_key' => env('OAUTH_SERVER_PRIVATE_KEY'), + 'public_key' => env('OAUTH_SERVER_PUBLIC_KEY'), + + /* + * Lifetimes, as ISO 8601 durations. + */ + 'ttl' => [ + 'auth_code' => env('OAUTH_SERVER_AUTH_CODE_TTL', 'PT10M'), + 'access_token' => env('OAUTH_SERVER_ACCESS_TOKEN_TTL', 'PT1H'), + 'refresh_token' => env('OAUTH_SERVER_REFRESH_TOKEN_TTL', 'P30D'), + 'id_token' => env('OAUTH_SERVER_ID_TOKEN_TTL', 'PT1H'), + ], + + /* + * `prompt=login` re-authenticates a console session older than this many seconds. + */ + 'reauthenticate_after' => (int) env('OAUTH_SERVER_REAUTHENTICATE_AFTER', 300), + + /* + * The console page that shows the consent screen. + */ + 'console_authorize_path' => '/oauth/authorize', +]; diff --git a/migrations/2026_10_24_000000_create_review_accesses_table.php b/migrations/2026_10_24_000000_create_review_accesses_table.php new file mode 100644 index 00000000..dd834906 --- /dev/null +++ b/migrations/2026_10_24_000000_create_review_accesses_table.php @@ -0,0 +1,48 @@ +bigIncrements('id'); + $table->uuid('uuid')->unique(); + $table->uuid('company_uuid')->nullable()->index(); + $table->string('subject_type', 64); + $table->uuid('subject_uuid'); + $table->boolean('enabled')->default(false); + $table->string('code')->nullable(); + $table->timestamp('code_generated_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->uuid('updated_by_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['subject_type', 'subject_uuid']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('review_accesses'); + } +}; diff --git a/migrations/2026_10_25_000001_create_oauth_server_tables.php b/migrations/2026_10_25_000001_create_oauth_server_tables.php new file mode 100644 index 00000000..e5476d1b --- /dev/null +++ b/migrations/2026_10_25_000001_create_oauth_server_tables.php @@ -0,0 +1,115 @@ +char('id', 36)->primary(); + $table->string('name'); + $table->text('description')->nullable(); + // bcrypt hash; null for a public client (a native or single-page app with no secret). + $table->string('secret', 100)->nullable(); + $table->json('redirect_uris'); + $table->json('allowed_scopes'); + $table->string('logo_url')->nullable(); + $table->string('website_url')->nullable(); + $table->string('privacy_url')->nullable(); + $table->string('terms_url')->nullable(); + // Built by Fleetbase (AppHelm). Shown as "Made by Fleetbase" on the consent screen. + $table->boolean('official')->default(false); + // Development clients may use http://127.0.0.1 or http://[::1] on any port (RFC 8252). + $table->boolean('development')->default(false); + $table->char('created_by_uuid', 36)->nullable(); + $table->timestamp('secret_rotated_at')->nullable(); + $table->timestamp('disabled_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('oauth_grants', function (Blueprint $table) { + $table->increments('id'); + $table->char('uuid', 36)->unique(); + $table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->char('client_id', 36); + // Null for a sign-in-only grant (openid, profile, email). + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + + $table->foreign('client_id')->references('id')->on('oauth_clients')->onUpdate('CASCADE')->onDelete('CASCADE'); + $table->index(['user_uuid', 'client_id', 'company_uuid']); + $table->index('company_uuid'); + }); + + Schema::create('oauth_auth_codes', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36); + $table->char('company_uuid', 36)->nullable(); + $table->json('scopes'); + $table->string('nonce', 255)->nullable(); + // When the user last authenticated to the console; becomes the ID token's auth_time. + $table->timestamp('auth_time')->nullable(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('oauth_access_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->char('grant_uuid', 36)->index(); + $table->char('client_id', 36); + $table->char('user_uuid', 36)->index(); + $table->char('company_uuid', 36)->nullable(); + // The code this token chain started from, so a replayed code can revoke it (RFC 6749 §4.1.2). + $table->string('auth_code_id', 100)->nullable()->index(); + $table->json('scopes'); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamps(); + }); + + Schema::create('oauth_refresh_tokens', function (Blueprint $table) { + $table->string('id', 100)->primary(); + $table->string('access_token_id', 100)->index(); + $table->char('grant_uuid', 36)->index(); + $table->boolean('revoked')->default(false); + $table->dateTime('expires_at'); + $table->timestamp('created_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + Schema::dropIfExists('oauth_access_tokens'); + Schema::dropIfExists('oauth_auth_codes'); + Schema::dropIfExists('oauth_grants'); + Schema::dropIfExists('oauth_clients'); + } +}; diff --git a/src/Auth/OAuthServer/Entities/AccessTokenEntity.php b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php new file mode 100644 index 00000000..efb5da1f --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AccessTokenEntity.php @@ -0,0 +1,73 @@ +privateKey = $privateKey; + } + + public function toJwt(): Token + { + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->privateKey->getKeyContents(), $this->privateKey->getPassPhrase() ?? ''), + InMemory::plainText('empty', 'empty') + ); + + // Whole seconds, so the JWT's dates are integers (lcobucci writes microseconds as floats). + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->issuedBy($this->issuer) + ->permittedFor($this->getClient()->getIdentifier()) + ->identifiedBy($this->getIdentifier()) + ->issuedAt($now) + ->canOnlyBeUsedAfter($now) + ->expiresAt(new \DateTimeImmutable('@' . $this->getExpiryDateTime()->getTimestamp())) + ->relatedTo((string) $this->getUserIdentifier()) + ->withClaim('scopes', array_map(fn ($scope) => $scope->getIdentifier(), $this->getScopes())); + + if ($this->keyId) { + $builder = $builder->withHeader('kid', $this->keyId); + } + + return $builder->getToken($config->signer(), $config->signingKey()); + } + + public function __toString() + { + return $this->jwt ??= $this->toJwt()->toString(); + } +} diff --git a/src/Auth/OAuthServer/Entities/AuthCodeEntity.php b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php new file mode 100644 index 00000000..c4cad06b --- /dev/null +++ b/src/Auth/OAuthServer/Entities/AuthCodeEntity.php @@ -0,0 +1,15 @@ + $redirectUris + */ + public function __construct(string $identifier, string $name, array $redirectUris, bool $confidential) + { + $this->setIdentifier($identifier); + $this->name = $name; + $this->redirectUri = $redirectUris; + $this->isConfidential = $confidential; + } +} diff --git a/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php new file mode 100644 index 00000000..eccbf7e2 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/RefreshTokenEntity.php @@ -0,0 +1,13 @@ +setIdentifier($identifier); + } +} diff --git a/src/Auth/OAuthServer/Entities/UserEntity.php b/src/Auth/OAuthServer/Entities/UserEntity.php new file mode 100644 index 00000000..102493b3 --- /dev/null +++ b/src/Auth/OAuthServer/Entities/UserEntity.php @@ -0,0 +1,19 @@ +setIdentifier($uuid); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/AuthorizeError.php b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php new file mode 100644 index 00000000..9730a173 --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/AuthorizeError.php @@ -0,0 +1,48 @@ +redirectUri !== null; + } + + /** + * The URL to send the user back to the app with the error, including `iss` (RFC 9207). + */ + public function redirectUrl(string $issuer): ?string + { + if (!$this->canRedirect()) { + return null; + } + + $params = array_filter([ + 'error' => $this->error, + 'error_description' => $this->description, + 'state' => $this->state, + 'iss' => $issuer, + ], fn ($value) => $value !== null && $value !== ''); + + $separator = str_contains($this->redirectUri, '?') ? '&' : '?'; + + return $this->redirectUri . $separator . http_build_query($params); + } +} diff --git a/src/Auth/OAuthServer/Exceptions/TokenError.php b/src/Auth/OAuthServer/Exceptions/TokenError.php new file mode 100644 index 00000000..38cff67a --- /dev/null +++ b/src/Auth/OAuthServer/Exceptions/TokenError.php @@ -0,0 +1,47 @@ +error . '"'; + if ($this->description) { + $challenge .= ', error_description="' . addslashes($this->description) . '"'; + } + if ($this->scope) { + $challenge .= ', scope="' . $this->scope . '"'; + } + + return new JsonResponse( + array_filter(['error' => $this->error, 'error_description' => $this->description]), + $this->status, + ['WWW-Authenticate' => $challenge, 'Cache-Control' => 'no-store'] + ); + } +} diff --git a/src/Auth/OAuthServer/IdTokenResponse.php b/src/Auth/OAuthServer/IdTokenResponse.php new file mode 100644 index 00000000..2ad36af8 --- /dev/null +++ b/src/Auth/OAuthServer/IdTokenResponse.php @@ -0,0 +1,84 @@ + $scope->getIdentifier(), $accessToken->getScopes()); + $params = ['scope' => implode(' ', $scopes)]; + + if (!in_array('openid', $scopes, true)) { + return $params; + } + + $user = User::where('uuid', $accessToken->getUserIdentifier())->first(); + if (!$user) { + return $params; + } + + $company = $this->context->companyUuid ? Company::on('mysql')->where('uuid', $this->context->companyUuid)->first() : null; + $config = Configuration::forAsymmetricSigner( + new Sha256(), + InMemory::plainText($this->keys->privateKeyContents()), + InMemory::plainText($this->keys->publicKeyContents()) + ); + + $now = new \DateTimeImmutable('@' . time()); + $builder = $config->builder() + ->withHeader('kid', $this->keys->keyId()) + ->issuedBy($this->issuer) + ->permittedFor($accessToken->getClient()->getIdentifier()) + ->issuedAt($now) + ->expiresAt($now->add($this->ttl)) + ->withClaim('at_hash', static::accessTokenHash((string) $accessToken)); + + foreach (OAuthClaims::forUser($user, $scopes, $company) as $claim => $value) { + $builder = $claim === 'sub' ? $builder->relatedTo($value) : $builder->withClaim($claim, $value); + } + + if ($this->context->authTime) { + $builder = $builder->withClaim('auth_time', $this->context->authTime->getTimestamp()); + } + + if ($this->context->nonce) { + $builder = $builder->withClaim('nonce', $this->context->nonce); + } + + $params['id_token'] = $builder->getToken($config->signer(), $config->signingKey())->toString(); + + return $params; + } + + /** + * OIDC Core §3.1.3.6: base64url of the left half of the SHA-256 of the access token. + */ + public static function accessTokenHash(string $accessToken): string + { + return OAuthKeys::base64url(substr(hash('sha256', $accessToken, true), 0, 16)); + } +} diff --git a/src/Auth/OAuthServer/IssueContext.php b/src/Auth/OAuthServer/IssueContext.php new file mode 100644 index 00000000..7ba689fc --- /dev/null +++ b/src/Auth/OAuthServer/IssueContext.php @@ -0,0 +1,36 @@ +grantUuid = $grantUuid; + $context->companyUuid = $companyUuid; + + return $context; + } +} diff --git a/src/Auth/OAuthServer/OAuthClaims.php b/src/Auth/OAuthServer/OAuthClaims.php new file mode 100644 index 00000000..a710f713 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthClaims.php @@ -0,0 +1,50 @@ + $scopes + * + * @return array + */ + public static function forUser(User $user, array $scopes, ?Company $company = null): array + { + $claims = ['sub' => static::subject($user)]; + + if (in_array('profile', $scopes, true)) { + $claims['name'] = $user->name; + $claims['picture'] = $user->avatar_url; + $claims['zoneinfo'] = $user->timezone; + $claims['updated_at'] = $user->updated_at?->getTimestamp(); + } + + if (in_array('email', $scopes, true)) { + $claims['email'] = $user->email; + $claims['email_verified'] = $user->email_verified_at !== null; + } + + // The organization the tokens are bound to, so the app knows which one the user picked. + if ($company && OAuthScopes::requiresOrganization($scopes)) { + $claims['org_id'] = $company->public_id; + $claims['org_name'] = $company->name; + } + + return $claims; + } + + /** + * The stable identifier for the user: their public_id, which never changes or gets reused. + */ + public static function subject(User $user): string + { + return (string) ($user->public_id ?: $user->uuid); + } +} diff --git a/src/Auth/OAuthServer/OAuthKeys.php b/src/Auth/OAuthServer/OAuthKeys.php new file mode 100644 index 00000000..842ae58a --- /dev/null +++ b/src/Auth/OAuthServer/OAuthKeys.php @@ -0,0 +1,137 @@ +load('private'); + } + + public function publicKeyContents(): string + { + return $this->load('public'); + } + + public function privateKey(): CryptKey + { + return new CryptKey($this->privateKeyContents(), null, false); + } + + public function publicKey(): CryptKey + { + return new CryptKey($this->publicKeyContents(), null, false); + } + + /** + * Whether a key pair is configured. + */ + public function configured(): bool + { + try { + return $this->privateKeyContents() !== '' && $this->publicKeyContents() !== ''; + } catch (\RuntimeException $e) { + return false; + } + } + + /** + * The defuse key league encrypts code and refresh-token payloads with. + * + * A Key object (rather than a password string) skips defuse's per-call PBKDF2 stretching, + * which is meant for human passwords and costs tens of milliseconds per token. + */ + public function encryptionKey(): Key + { + $appKey = (string) config('app.key'); + if (str_starts_with($appKey, 'base64:')) { + $appKey = base64_decode(substr($appKey, 7)); + } + if ($appKey === '') { + throw new \RuntimeException('APP_KEY is not set; the OAuth provider needs it to encrypt authorization codes.'); + } + + $bytes = hash_hkdf('sha256', $appKey, Key::KEY_BYTE_SIZE, 'fleetbase-oauth-server-payloads'); + + return Key::loadFromAsciiSafeString(Encoding::saveBytesToChecksummedAsciiSafeString(Key::KEY_CURRENT_VERSION, $bytes)); + } + + /** + * The JWK for the public key, with its RFC 7638 thumbprint as `kid`. + * + * @return array + */ + public function jwk(): array + { + $details = openssl_pkey_get_details(openssl_pkey_get_public($this->publicKeyContents())); + $n = static::base64url($details['rsa']['n']); + $e = static::base64url($details['rsa']['e']); + + return [ + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'kid' => static::base64url(hash('sha256', json_encode(['e' => $e, 'kty' => 'RSA', 'n' => $n]), true)), + 'n' => $n, + 'e' => $e, + ]; + } + + public function keyId(): string + { + return $this->jwk()['kid']; + } + + /** + * Generate a new RSA key pair. + * + * @return array{private: string, public: string} + */ + public static function generate(int $bits = 4096): array + { + $resource = openssl_pkey_new(['private_key_bits' => $bits, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($resource, $private); + $public = openssl_pkey_get_details($resource)['key']; + + return ['private' => $private, 'public' => $public]; + } + + public static function keyPath(string $type): string + { + return storage_path("oauth-{$type}.key"); + } + + public static function base64url(string $bytes): string + { + return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); + } + + protected function load(string $type): string + { + $configured = str_replace('\\n', "\n", (string) config("oauth-server.{$type}_key", '')); + if (trim($configured) !== '') { + return $configured; + } + + $path = static::keyPath($type); + if (is_readable($path)) { + return (string) file_get_contents($path); + } + + throw new \RuntimeException("The OAuth provider's {$type} key is not configured. Run `php artisan oauth-server:keys` or set OAUTH_SERVER_" . strtoupper($type) . '_KEY.'); + } +} diff --git a/src/Auth/OAuthServer/OAuthOrganizations.php b/src/Auth/OAuthServer/OAuthOrganizations.php new file mode 100644 index 00000000..d23a015a --- /dev/null +++ b/src/Auth/OAuthServer/OAuthOrganizations.php @@ -0,0 +1,104 @@ +where('user_uuid', $user->uuid) + ->where('company_uuid', $company->uuid) + ->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'active'); + }) + ->first(); + } + + public static function canShare(User $user, Company $company): bool + { + if (!static::userIsActive($user)) { + return false; + } + + $membership = static::membership($user, $company); + if (!$membership) { + return false; + } + + if ($user->isAdmin() || $company->owner_uuid === $user->uuid) { + return true; + } + + if (static::$administratorCheck) { + return (bool) (static::$administratorCheck)($membership); + } + + return $membership->hasRole('Administrator'); + } + + public static function userIsActive(User $user): bool + { + return ($user->status ?? 'active') !== 'inactive' && !$user->trashed(); + } + + /** + * The user's organizations for the consent screen's picker, with whether each can be shared. + * + * @return Collection + */ + public static function forPicker(User $user): Collection + { + $companies = Company::on('mysql')->whereIn('uuid', CompanyUser::on('mysql')->where('user_uuid', $user->uuid)->pluck('company_uuid')) + ->whereNotNull('owner_uuid') + ->orderBy('name') + ->get(); + + return $companies->map(fn (Company $company) => [ + 'id' => $company->public_id, + 'uuid' => $company->uuid, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'can_share' => static::canShare($user, $company), + ])->values(); + } + + /** + * Find one of the user's organizations by public_id or uuid. + */ + public static function find(User $user, ?string $id): ?Company + { + if (!$id) { + return null; + } + + $company = Company::on('mysql')->where(fn ($query) => $query->where('public_id', $id)->orWhere('uuid', $id))->first(); + if (!$company || !static::membership($user, $company)) { + return null; + } + + return $company; + } +} diff --git a/src/Auth/OAuthServer/OAuthScopes.php b/src/Auth/OAuthServer/OAuthScopes.php new file mode 100644 index 00000000..1081cb2d --- /dev/null +++ b/src/Auth/OAuthServer/OAuthScopes.php @@ -0,0 +1,141 @@ + "See {organization}'s storefronts and networks, with their public keys", + * 'organization' => true, + * ]); + * + * - description: the consent-screen line, in plain words. `{organization}` is replaced with the + * organization the user picked. + * - organization: true when the scope reads or changes an organization's data. A request with + * any such scope makes the user pick an organization they administer, and binds the tokens to it. + * - review_access_types: App Review subject types (Fleetbase\Support\AppReview) this scope lets + * the app turn on or off through /oauth/v1/review-access/{type}/{id}. + */ +class OAuthScopes +{ + /** + * @var array}> + */ + protected static array $scopes = []; + + /** + * Register (or replace) a scope. + */ + public static function register(string $scope, array $definition): void + { + if (!preg_match('/^[a-z][a-z0-9_.\-]*(:[a-z0-9_.\-]+)*$/', $scope)) { + throw new \InvalidArgumentException("Invalid OAuth scope name [{$scope}]."); + } + + static::$scopes[$scope] = [ + 'description' => (string) ($definition['description'] ?? $scope), + 'organization' => (bool) ($definition['organization'] ?? false), + 'review_access_types' => array_values($definition['review_access_types'] ?? []), + ]; + } + + /** + * The scopes core registers. Called from CoreServiceProvider::boot(). + */ + public static function registerDefaults(): void + { + static::register('openid', ['description' => 'Sign you in with your Fleetbase account']); + static::register('profile', ['description' => 'See your name and profile photo']); + static::register('email', ['description' => 'See your email address']); + static::register('organization:read', [ + 'description' => "See {organization}'s name, logo, currency and country", + 'organization' => true, + ]); + } + + public static function has(string $scope): bool + { + return isset(static::$scopes[$scope]); + } + + /** + * @return array}> + */ + public static function all(): array + { + return static::$scopes; + } + + public static function get(string $scope): ?array + { + return static::$scopes[$scope] ?? null; + } + + /** + * Whether any of the scopes needs an organization. + * + * @param array $scopes + */ + public static function requiresOrganization(array $scopes): bool + { + foreach ($scopes as $scope) { + if (static::$scopes[$scope]['organization'] ?? false) { + return true; + } + } + + return false; + } + + /** + * The scope that covers App Review access for a subject type, if any. + */ + public static function scopeForReviewAccessType(string $type): ?string + { + foreach (static::$scopes as $scope => $definition) { + if (in_array($type, $definition['review_access_types'], true)) { + return $scope; + } + } + + return null; + } + + /** + * The consent-screen lines for a set of scopes. + * + * @param array $scopes + * + * @return array + */ + public static function describe(array $scopes, ?string $organizationName = null): array + { + $lines = []; + foreach ($scopes as $scope) { + $definition = static::$scopes[$scope] ?? null; + if (!$definition) { + continue; + } + + $lines[] = [ + 'scope' => $scope, + 'description' => str_replace('{organization}', $organizationName ?: 'your organization', $definition['description']), + 'organization' => $definition['organization'], + ]; + } + + return $lines; + } + + /** + * Forget every registration. For tests. + */ + public static function flush(): void + { + static::$scopes = []; + } +} diff --git a/src/Auth/OAuthServer/OAuthServer.php b/src/Auth/OAuthServer/OAuthServer.php new file mode 100644 index 00000000..f5bdf991 --- /dev/null +++ b/src/Auth/OAuthServer/OAuthServer.php @@ -0,0 +1,602 @@ +keys ??= new OAuthKeys(); + } + + public function keys(): OAuthKeys + { + return $this->keys; + } + + /** + * The issuer identifier, without a trailing slash. + */ + public function issuer(): string + { + $issuer = config('oauth-server.issuer') ?: config('app.url'); + + return rtrim((string) $issuer, '/'); + } + + public function endpoint(string $path): string + { + return $this->issuer() . '/' . ltrim($path, '/'); + } + + public function ttl(string $name): \DateInterval + { + return new \DateInterval((string) config("oauth-server.ttl.{$name}", ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'][$name])); + } + + /** + * League's authorization server, wired to Fleetbase's repositories for one request. + */ + public function authorizationServer(IssueContext $context): AuthorizationServer + { + $server = new AuthorizationServer( + new ClientRepository(), + new AccessTokenRepository($context, $this->issuer(), $this->keys->keyId()), + new ScopeRepository(), + $this->keys->privateKey(), + $this->keys->encryptionKey(), + new IdTokenResponse($context, $this->keys, $this->issuer(), $this->ttl('id_token')) + ); + + $authCodeGrant = new AuthCodeGrant(new AuthCodeRepository($context), new RefreshTokenRepository($context), $this->ttl('auth_code')); + $authCodeGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($authCodeGrant, $this->ttl('access_token')); + + $refreshTokenGrant = new RefreshTokenGrant(new RefreshTokenRepository($context)); + $refreshTokenGrant->setRefreshTokenTTL($this->ttl('refresh_token')); + $server->enableGrantType($refreshTokenGrant, $this->ttl('access_token')); + + return $server; + } + + public function resourceServer(): ResourceServer + { + return new ResourceServer(new AccessTokenRepository(new IssueContext(), $this->issuer()), $this->keys->publicKey()); + } + + /* + |-------------------------------------------------------------------------- + | Authorization (the consent screen) + |-------------------------------------------------------------------------- + */ + + /** + * Check an authorization request's parameters. + * + * @param array $params the authorization request's query parameters + * + * @throws AuthorizeError + */ + public function validateAuthorizationRequest(array $params): ValidatedAuthorization + { + $string = fn (string $key) => is_string($params[$key] ?? null) && $params[$key] !== '' ? $params[$key] : null; + + $client = (new ClientRepository())->findActive($string('client_id')); + if (!$client) { + throw new AuthorizeError('invalid_client', 'This app is not registered with Fleetbase, or it has been turned off.'); + } + + $redirectUri = $string('redirect_uri'); + if (!$client->allowsRedirectUri($redirectUri)) { + throw new AuthorizeError('invalid_request', "The redirect URI doesn't match one registered for {$client->name}."); + } + + // From here on the redirect URI is trusted, so errors go back to the app. + $state = $string('state'); + $fail = fn (string $error, string $description) => new AuthorizeError($error, $description, $redirectUri, $state); + + foreach ($params as $key => $value) { + if (!is_string($value)) { + throw $fail('invalid_request', "The parameter `{$key}` must be a single value."); + } + } + + if ($string('response_type') !== 'code') { + throw $fail('unsupported_response_type', 'Only response_type=code is supported.'); + } + + if (!$string('code_challenge')) { + throw $fail('invalid_request', 'PKCE is required: send code_challenge and code_challenge_method=S256.'); + } + + if ($string('code_challenge_method') !== 'S256') { + throw $fail('invalid_request', 'code_challenge_method must be S256.'); + } + + $scopes = array_values(array_unique(array_filter(explode(' ', (string) $string('scope'))))); + if (!$scopes) { + throw $fail('invalid_scope', 'Ask for at least one scope.'); + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope) || !$client->allowsScope($scope)) { + throw $fail('invalid_scope', "The scope `{$scope}` isn't available to this app."); + } + } + + $prompt = array_values(array_filter(explode(' ', (string) $string('prompt')))); + if (in_array('none', $prompt, true)) { + // There is no silent authorization: the consent screen runs in the console, which + // the app can't check without showing it. + throw $fail(count($prompt) > 1 ? 'invalid_request' : 'interaction_required', 'prompt=none is not supported; the user has to see the consent screen.'); + } + + $nonce = $string('nonce'); + if ($nonce !== null && strlen($nonce) > 255) { + throw $fail('invalid_request', 'nonce must be at most 255 characters.'); + } + + $maxAge = $string('max_age'); + if ($maxAge !== null && !ctype_digit($maxAge)) { + throw $fail('invalid_request', 'max_age must be a whole number of seconds.'); + } + + try { + $request = $this->authorizationServer(new IssueContext())->validateAuthorizationRequest($this->queryRequest($params)); + } catch (OAuthServerException $e) { + throw $fail($e->getErrorType(), $e->getHint() ?: $e->getMessage()); + } + + return new ValidatedAuthorization($client, $request, $scopes, $redirectUri, $state, $nonce, $prompt, $maxAge !== null ? (int) $maxAge : null); + } + + /** + * Whether the user has to sign in again first (`prompt=login`, or `max_age` exceeded). + */ + public function needsReauthentication(ValidatedAuthorization $authorization, ?\DateTimeInterface $authTime): bool + { + if (!$authTime) { + return $authorization->prompts('login') || $authorization->maxAge !== null; + } + + $age = time() - $authTime->getTimestamp(); + if ($authorization->prompts('login') && $age > (int) config('oauth-server.reauthenticate_after', 300)) { + return true; + } + + return $authorization->maxAge !== null && $age > $authorization->maxAge; + } + + /** + * The user's live grant for this app and organization, if any. + */ + public function existingGrant(User $user, OAuthClient $client, ?Company $company): ?OAuthGrant + { + return OAuthGrant::active() + ->where('user_uuid', $user->uuid) + ->where('client_id', $client->id) + ->where(fn ($query) => $company ? $query->where('company_uuid', $company->uuid) : $query->whereNull('company_uuid')) + ->first(); + } + + /** + * Whether the consent screen can be skipped: sign-in only, already approved, not forced. + */ + public function canSkipConsent(ValidatedAuthorization $authorization, User $user): bool + { + if ($authorization->requiresOrganization() || $authorization->prompts('consent')) { + return false; + } + + $grant = $this->existingGrant($user, $authorization->client, null); + + return $grant && empty(array_diff($authorization->scopes, $grant->scopes ?? [])); + } + + /** + * The user approved: record the grant, issue the code and return where to send the browser. + * + * @return array{redirect: string, grant: OAuthGrant, created: bool} + * + * @throws AuthorizeError when the organization can't be shared + */ + public function approve(ValidatedAuthorization $authorization, User $user, ?Company $company, ?\DateTimeInterface $authTime): array + { + if ($authorization->requiresOrganization()) { + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + throw new AuthorizeError('access_denied', 'Choose an organization you administer to share with this app.'); + } + } else { + $company = null; + } + + [$grant, $created] = DB::connection((new OAuthGrant())->getConnectionName())->transaction(function () use ($authorization, $user, $company) { + $grant = $this->existingGrant($user, $authorization->client, $company); + $created = $grant === null; + $grant ??= new OAuthGrant([ + 'user_uuid' => $user->uuid, + 'client_id' => $authorization->client->id, + 'company_uuid' => $company?->uuid, + ]); + $grant->scopes = array_values(array_unique(array_merge($grant->scopes ?? [], $authorization->scopes))); + $grant->save(); + + return [$grant, $created]; + }); + + $context = IssueContext::forGrant($grant->uuid, $company?->uuid); + $context->nonce = $authorization->nonce; + $context->authTime = $authTime; + + $authorization->request->setUser(new UserEntity($user->uuid)); + $authorization->request->setAuthorizationApproved(true); + + $response = $this->authorizationServer($context)->completeAuthorizationRequest($authorization->request, new Psr7Response()); + + return [ + 'redirect' => $this->withIssuer($response->getHeaderLine('Location')), + 'grant' => $grant, + 'created' => $created, + ]; + } + + /** + * The user declined: where to send the browser. + */ + public function deny(ValidatedAuthorization $authorization): string + { + return (new AuthorizeError('access_denied', 'The user declined to share access.', $authorization->redirectUri, $authorization->state))->redirectUrl($this->issuer()); + } + + /* + |-------------------------------------------------------------------------- + | Token endpoint + |-------------------------------------------------------------------------- + */ + + public function respondToTokenRequest(Request $request): Response + { + $context = new IssueContext(); + + try { + $this->loadTokenContext($request->request->all(), $context); + $response = $this->authorizationServer($context)->respondToAccessTokenRequest($this->psrRequest($request), new Psr7Response()); + + if ($context->grantUuid) { + OAuthGrant::find($context->grantUuid)?->touchLastUsed(); + } + } catch (OAuthServerException $e) { + $response = $e->generateHttpResponse(new Psr7Response()); + } + + return $this->laravelResponse($response); + } + + /** + * Find the grant and organization behind the code or refresh token being redeemed. + * + * @param array $params + * + * @throws OAuthServerException when the grant no longer allows new tokens + */ + protected function loadTokenContext(array $params, IssueContext $context): void + { + $crypt = new PayloadCrypt($this->keys->encryptionKey()); + + if (($params['grant_type'] ?? null) === 'authorization_code') { + $payload = $crypt->open($params['code'] ?? null); + $code = isset($payload['auth_code_id']) ? OAuthAuthCode::find($payload['auth_code_id']) : null; + if (!$code) { + return; + } + + $context->grantUuid = $code->grant_uuid; + $context->companyUuid = $code->company_uuid; + $context->nonce = $code->nonce; + $context->authTime = $code->auth_time; + $context->authCodeId = $code->id; + + // A replayed code is left to league, whose revoked-code check also revokes its tokens. + if (!$code->revoked) { + $this->assertGrantUsable(OAuthGrant::find($code->grant_uuid)); + } + + return; + } + + if (($params['grant_type'] ?? null) === 'refresh_token') { + $payload = $crypt->open($params['refresh_token'] ?? null); + $refreshToken = isset($payload['refresh_token_id']) ? OAuthRefreshToken::find($payload['refresh_token_id']) : null; + if (!$refreshToken) { + return; + } + + $grant = OAuthGrant::find($refreshToken->grant_uuid); + $context->grantUuid = $refreshToken->grant_uuid; + $context->companyUuid = $grant?->company_uuid; + $context->authCodeId = OAuthAccessToken::where('id', $refreshToken->access_token_id)->value('auth_code_id'); + + // A replayed refresh token is left to league, whose revoked check revokes the grant. + if (!$refreshToken->revoked) { + $this->assertGrantUsable($grant); + } + } + } + + /** + * @throws OAuthServerException + */ + protected function assertGrantUsable(?OAuthGrant $grant): void + { + if (!$grant || $grant->isRevoked()) { + throw OAuthServerException::invalidGrant('The user removed this app\'s access.'); + } + + $user = User::where('uuid', $grant->user_uuid)->first(); + if (!$user || !OAuthOrganizations::userIsActive($user)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The account that authorized this app is no longer active.'); + } + + if ($grant->company_uuid) { + $company = Company::on('mysql')->where('uuid', $grant->company_uuid)->first(); + if (!$company || !OAuthOrganizations::canShare($user, $company)) { + $grant->revoke(); + + throw OAuthServerException::invalidGrant('The user no longer administers the organization this app was given.'); + } + } + } + + /* + |-------------------------------------------------------------------------- + | Revocation (RFC 7009) + |-------------------------------------------------------------------------- + */ + + /** + * Revoke a refresh token (which disconnects the app: the whole grant) or an access token. + * + * Answers 200 whether or not the token was known, as RFC 7009 §2.2 asks. + */ + public function revoke(Request $request): Response|JsonResponse + { + [$clientId, $secret] = $this->clientCredentials($request); + $client = (new ClientRepository())->findActive($clientId); + $authenticated = $client && ($client->isConfidential() ? $client->verifySecret($secret) : ($secret === null || $secret === '')); + if (!$authenticated) { + return new JsonResponse(['error' => 'invalid_client', 'error_description' => 'Client authentication failed.'], 401, ['WWW-Authenticate' => 'Basic realm="OAuth"', 'Cache-Control' => 'no-store']); + } + + $token = $request->input('token'); + if (!is_string($token) || $token === '') { + return new JsonResponse(['error' => 'invalid_request', 'error_description' => 'Send the token to revoke.'], 400, ['Cache-Control' => 'no-store']); + } + + $payload = (new PayloadCrypt($this->keys->encryptionKey()))->open($token); + if ($payload && ($payload['client_id'] ?? null) === $client->id && isset($payload['refresh_token_id'])) { + $refreshToken = OAuthRefreshToken::find($payload['refresh_token_id']); + if ($refreshToken) { + OAuthGrant::find($refreshToken->grant_uuid)?->revoke(); + } + } elseif ($tokenId = $this->accessTokenId($token)) { + OAuthAccessToken::where('id', $tokenId)->where('client_id', $client->id)->update(['revoked' => true, 'updated_at' => now()]); + } + + return new Response('', 200, ['Cache-Control' => 'no-store']); + } + + /** + * The `jti` of an access token this server signed, or null. + */ + protected function accessTokenId(string $jwt): ?string + { + try { + $config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $token = $config->parser()->parse($jwt); + if (!$config->validator()->validate($token, new SignedWith(new Sha256(), InMemory::plainText($this->keys->publicKeyContents())))) { + return null; + } + + return $token->claims()->get('jti'); + } catch (\Throwable $e) { + return null; + } + } + + /** + * Client credentials from HTTP Basic (client_secret_basic) or the body (client_secret_post). + * + * @return array{0: ?string, 1: ?string} + */ + protected function clientCredentials(Request $request): array + { + $header = (string) $request->header('Authorization'); + if (stripos($header, 'Basic ') === 0) { + $decoded = base64_decode(substr($header, 6), true); + if ($decoded !== false && str_contains($decoded, ':')) { + [$id, $secret] = explode(':', $decoded, 2); + + return [urldecode($id), urldecode($secret)]; + } + } + + $id = $request->input('client_id'); + $secret = $request->input('client_secret'); + + return [is_string($id) ? $id : null, is_string($secret) ? $secret : null]; + } + + /* + |-------------------------------------------------------------------------- + | Protected resources + |-------------------------------------------------------------------------- + */ + + /** + * Check the bearer token on a request to an OAuth-protected endpoint. + * + * @param array $scopes every scope the endpoint needs + * + * @throws TokenError + */ + public function authenticate(Request $request, array $scopes = []): OAuthAccessToken + { + try { + $validated = $this->resourceServer()->validateAuthenticatedRequest($this->psrRequest($request)); + } catch (OAuthServerException $e) { + throw TokenError::invalidToken(); + } + + $token = OAuthAccessToken::with(['grant', 'client', 'user', 'company'])->find($validated->getAttribute('oauth_access_token_id')); + if (!$token || !$token->isUsable() || !$token->grant || $token->grant->isRevoked() || !$token->client || $token->client->isDisabled()) { + throw TokenError::invalidToken(); + } + + if (!$token->user || !OAuthOrganizations::userIsActive($token->user)) { + throw TokenError::invalidToken(); + } + + if ($token->company_uuid && (!$token->company || !OAuthOrganizations::canShare($token->user, $token->company))) { + $token->grant->revoke(); + + throw TokenError::invalidToken('The user no longer administers the organization this app was given.'); + } + + // The client's registration is checked too, so an admin removing a scope from an app + // takes it away from tokens already issued. + foreach ($scopes as $scope) { + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + throw TokenError::insufficientScope($scope); + } + } + + $token->grant->touchLastUsed(); + + return $token; + } + + /* + |-------------------------------------------------------------------------- + | Discovery + |-------------------------------------------------------------------------- + */ + + /** + * OpenID Connect Discovery 1.0 / RFC 8414 metadata. + * + * @return array + */ + public function discovery(): array + { + return [ + 'issuer' => $this->issuer(), + 'authorization_endpoint' => $this->endpoint('oauth/authorize'), + 'token_endpoint' => $this->endpoint('oauth/token'), + 'userinfo_endpoint' => $this->endpoint('oauth/userinfo'), + 'jwks_uri' => $this->endpoint('oauth/jwks'), + 'revocation_endpoint' => $this->endpoint('oauth/revoke'), + 'scopes_supported' => array_keys(OAuthScopes::all()), + 'response_types_supported' => ['code'], + 'response_modes_supported' => ['query'], + 'grant_types_supported' => ClientRepository::GRANT_TYPES, + 'subject_types_supported' => ['public'], + 'id_token_signing_alg_values_supported' => ['RS256'], + 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'revocation_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], + 'code_challenge_methods_supported' => ['S256'], + 'prompt_values_supported' => ['login', 'consent'], + 'claims_supported' => ['sub', 'iss', 'aud', 'exp', 'iat', 'auth_time', 'nonce', 'at_hash', 'name', 'picture', 'zoneinfo', 'updated_at', 'email', 'email_verified', 'org_id', 'org_name'], + 'authorization_response_iss_parameter_supported' => true, + ]; + } + + /* + |-------------------------------------------------------------------------- + | PSR-7 plumbing + |-------------------------------------------------------------------------- + */ + + public function psrRequest(Request $request): ServerRequestInterface + { + $factory = new Psr17Factory(); + + return (new PsrHttpFactory($factory, $factory, $factory, $factory))->createRequest($request); + } + + /** + * A GET request carrying only these query parameters, for league's authorization checks. + * + * @param array $params + */ + protected function queryRequest(array $params): ServerRequestInterface + { + return (new ServerRequest('GET', $this->endpoint('oauth/authorize') . '?' . http_build_query($params)))->withQueryParams($params); + } + + public function laravelResponse(ResponseInterface $response): Response + { + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return new Response((string) $response->getBody(), $response->getStatusCode(), $headers); + } + + /** + * Add `iss` to an authorization response (RFC 9207), so the app can detect mix-up attacks. + */ + protected function withIssuer(string $location): string + { + return $location . (str_contains($location, '?') ? '&' : '?') . 'iss=' . rawurlencode($this->issuer()); + } +} diff --git a/src/Auth/OAuthServer/PayloadCrypt.php b/src/Auth/OAuthServer/PayloadCrypt.php new file mode 100644 index 00000000..302ee449 --- /dev/null +++ b/src/Auth/OAuthServer/PayloadCrypt.php @@ -0,0 +1,41 @@ +setEncryptionKey($key); + } + + /** + * @return array|null + */ + public function open(mixed $payload): ?array + { + if (!is_string($payload) || $payload === '') { + return null; + } + + try { + $decoded = json_decode($this->decrypt($payload), true); + } catch (\Throwable $e) { + return null; + } + + return is_array($decoded) ? $decoded : null; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php new file mode 100644 index 00000000..218160b7 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AccessTokenRepository.php @@ -0,0 +1,60 @@ +issuer, $this->keyId); + $token->setClient($clientEntity); + $token->setUserIdentifier($userIdentifier); + foreach ($scopes as $scope) { + $token->addScope($scope); + } + + return $token; + } + + public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity) + { + if (!$this->context->grantUuid) { + throw new \LogicException('An access token can only be issued under a grant.'); + } + + OAuthAccessToken::create([ + 'id' => $accessTokenEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $accessTokenEntity->getClient()->getIdentifier(), + 'user_uuid' => $accessTokenEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'auth_code_id' => $this->context->authCodeId, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $accessTokenEntity->getScopes()), + 'revoked' => false, + 'expires_at' => $accessTokenEntity->getExpiryDateTime(), + ]); + } + + public function revokeAccessToken($tokenId) + { + OAuthAccessToken::where('id', $tokenId)->update(['revoked' => true, 'updated_at' => now()]); + } + + public function isAccessTokenRevoked($tokenId) + { + $token = OAuthAccessToken::find($tokenId); + + return !$token || $token->revoked; + } +} diff --git a/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php new file mode 100644 index 00000000..4da965c9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/AuthCodeRepository.php @@ -0,0 +1,73 @@ +context->grantUuid) { + throw new \LogicException('An authorization code can only be issued under a grant.'); + } + + OAuthAuthCode::create([ + 'id' => $authCodeEntity->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'user_uuid' => $authCodeEntity->getUserIdentifier(), + 'company_uuid' => $this->context->companyUuid, + 'scopes' => array_map(fn ($scope) => $scope->getIdentifier(), $authCodeEntity->getScopes()), + 'nonce' => $this->context->nonce, + 'auth_time' => $this->context->authTime, + 'revoked' => false, + 'expires_at' => $authCodeEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeAuthCode($codeId) + { + OAuthAuthCode::where('id', $codeId)->update(['revoked' => true]); + } + + /** + * A code that was already redeemed is being presented again. RFC 6749 §4.1.2: the server + * SHOULD revoke every token issued from it, since one of the two holders is an attacker. + */ + public function isAuthCodeRevoked($codeId) + { + $code = OAuthAuthCode::find($codeId); + if (!$code) { + return true; + } + + if ($code->revoked) { + $tokenIds = OAuthAccessToken::where('auth_code_id', $codeId)->pluck('id')->all(); + if ($tokenIds) { + OAuthAccessToken::whereIn('id', $tokenIds)->update(['revoked' => true, 'updated_at' => now()]); + OAuthRefreshToken::whereIn('access_token_id', $tokenIds)->update(['revoked' => true]); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ClientRepository.php b/src/Auth/OAuthServer/Repositories/ClientRepository.php new file mode 100644 index 00000000..d4ed7ff8 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ClientRepository.php @@ -0,0 +1,49 @@ +findActive($clientIdentifier); + if (!$client) { + return null; + } + + return new ClientEntity($client->id, $client->name, $client->redirect_uris ?? [], $client->isConfidential()); + } + + public function validateClient($clientIdentifier, $clientSecret, $grantType) + { + $client = $this->findActive($clientIdentifier); + if (!$client || !in_array($grantType, static::GRANT_TYPES, true)) { + return false; + } + + if ($client->isConfidential()) { + return $client->verifySecret($clientSecret); + } + + // A public client has no secret to present; sending one means it was misconfigured. + return $clientSecret === null || $clientSecret === ''; + } + + public function findActive($clientIdentifier): ?OAuthClient + { + if (!is_string($clientIdentifier) || !preg_match('/^[0-9a-f-]{36}$/i', $clientIdentifier)) { + return null; + } + + return OAuthClient::where('id', $clientIdentifier)->whereNull('disabled_at')->first(); + } +} diff --git a/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php new file mode 100644 index 00000000..a6677ba6 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/RefreshTokenRepository.php @@ -0,0 +1,67 @@ +context->grantUuid) { + throw new \LogicException('A refresh token can only be issued under a grant.'); + } + + OAuthRefreshToken::create([ + 'id' => $refreshTokenEntity->getIdentifier(), + 'access_token_id' => $refreshTokenEntity->getAccessToken()->getIdentifier(), + 'grant_uuid' => $this->context->grantUuid, + 'revoked' => false, + 'expires_at' => $refreshTokenEntity->getExpiryDateTime(), + 'created_at' => now(), + ]); + } + + public function revokeRefreshToken($tokenId) + { + OAuthRefreshToken::where('id', $tokenId)->update(['revoked' => true]); + } + + /** + * Refresh tokens rotate on every use, so a revoked one coming back means it was copied: either + * the client or a thief already used it. Revoke the whole grant so neither copy keeps working + * (OAuth 2.0 Security BCP, RFC 9700 §4.14.2). + */ + public function isRefreshTokenRevoked($tokenId) + { + $token = OAuthRefreshToken::find($tokenId); + if (!$token) { + return true; + } + + if ($token->revoked) { + $grant = OAuthGrant::find($token->grant_uuid); + if ($grant && !$grant->isRevoked()) { + $grant->revoke(); + } + + return true; + } + + return false; + } +} diff --git a/src/Auth/OAuthServer/Repositories/ScopeRepository.php b/src/Auth/OAuthServer/Repositories/ScopeRepository.php new file mode 100644 index 00000000..c84be8a9 --- /dev/null +++ b/src/Auth/OAuthServer/Repositories/ScopeRepository.php @@ -0,0 +1,36 @@ +getIdentifier()); + if (!$client) { + return []; + } + + return array_values(array_filter($scopes, function ($scope) use ($client) { + return OAuthScopes::has($scope->getIdentifier()) && $client->allowsScope($scope->getIdentifier()); + })); + } +} diff --git a/src/Auth/OAuthServer/ValidatedAuthorization.php b/src/Auth/OAuthServer/ValidatedAuthorization.php new file mode 100644 index 00000000..bafd601a --- /dev/null +++ b/src/Auth/OAuthServer/ValidatedAuthorization.php @@ -0,0 +1,38 @@ + $scopes + * @param array $prompt + */ + public function __construct( + public OAuthClient $client, + public AuthorizationRequest $request, + public array $scopes, + public string $redirectUri, + public ?string $state, + public ?string $nonce, + public array $prompt, + public ?int $maxAge, + ) { + } + + public function requiresOrganization(): bool + { + return OAuthScopes::requiresOrganization($this->scopes); + } + + public function prompts(string $value): bool + { + return in_array($value, $this->prompt, true); + } +} diff --git a/src/Console/Commands/OAuthServerClient.php b/src/Console/Commands/OAuthServerClient.php new file mode 100644 index 00000000..199e048f --- /dev/null +++ b/src/Console/Commands/OAuthServerClient.php @@ -0,0 +1,120 @@ +argument('action')) { + 'create' => $this->create(), + 'rotate' => $this->rotate(), + 'list' => $this->list(), + default => $this->refuse('Unknown action; use create, rotate or list.'), + }; + } + + protected function create(): int + { + $name = $this->option('name'); + $redirects = $this->option('redirect'); + $scopes = $this->option('scope') ?: array_keys(OAuthScopes::all()); + if (!$name || !$redirects) { + return $this->refuse('--name and at least one --redirect are required.'); + } + + foreach ($redirects as $uri) { + if (!OAuthClient::isAcceptableRedirectUri($uri, (bool) $this->option('development'))) { + return $this->refuse("`{$uri}` must be https (http only for loopback on a --development app), without a fragment."); + } + } + foreach ($scopes as $scope) { + if (!OAuthScopes::has($scope)) { + return $this->refuse("`{$scope}` is not a registered scope."); + } + } + + $client = new OAuthClient([ + 'name' => $name, + 'redirect_uris' => array_values(array_unique($redirects)), + 'allowed_scopes' => array_values(array_unique($scopes)), + 'website_url' => $this->option('website'), + 'logo_url' => $this->option('logo'), + 'privacy_url' => $this->option('privacy'), + 'terms_url' => $this->option('terms'), + 'official' => (bool) $this->option('official'), + 'development' => (bool) $this->option('development'), + ]); + $secret = $this->option('public') ? null : $client->issueSecret(); + $client->save(); + + $this->info('Registered ' . $client->name . '.'); + $this->line('client_id: ' . $client->id); + if ($secret) { + $this->line('client_secret: ' . $secret); + $this->warn('Store the secret now; it is not shown again.'); + } + + return self::SUCCESS; + } + + protected function rotate(): int + { + $client = OAuthClient::find($this->argument('id')); + if (!$client || !$client->isConfidential()) { + return $this->refuse('No confidential app with that client id.'); + } + + $secret = $client->issueSecret(); + $client->save(); + + $this->line('client_secret: ' . $secret); + $this->warn('The old secret stopped working. Store the new one now; it is not shown again.'); + + return self::SUCCESS; + } + + protected function list(): int + { + $this->table(['client_id', 'name', 'type', 'redirect URIs', 'status'], OAuthClient::orderBy('name')->get()->map(fn (OAuthClient $client) => [ + $client->id, + $client->name, + $client->isConfidential() ? 'confidential' : 'public', + implode("\n", $client->redirect_uris ?? []), + $client->isDisabled() ? 'disabled' : 'active', + ])->all()); + + return self::SUCCESS; + } + + protected function refuse(string $message): int + { + $this->error($message); + + return self::FAILURE; + } +} diff --git a/src/Console/Commands/OAuthServerKeys.php b/src/Console/Commands/OAuthServerKeys.php new file mode 100644 index 00000000..cf141c97 --- /dev/null +++ b/src/Console/Commands/OAuthServerKeys.php @@ -0,0 +1,47 @@ +option('show')) { + $this->line('OAUTH_SERVER_PRIVATE_KEY="' . str_replace("\n", '\\n', trim($keys['private'])) . '"'); + $this->line('OAUTH_SERVER_PUBLIC_KEY="' . str_replace("\n", '\\n', trim($keys['public'])) . '"'); + + return self::SUCCESS; + } + + $private = OAuthKeys::keyPath('private'); + $public = OAuthKeys::keyPath('public'); + if ((file_exists($private) || file_exists($public)) && !$this->option('force')) { + $this->error('Keys already exist. Rotating them signs everyone out of every app; use --force to do it anyway.'); + + return self::FAILURE; + } + + file_put_contents($private, $keys['private']); + file_put_contents($public, $keys['public']); + @chmod($private, 0600); + @chmod($public, 0644); + + $this->info('Wrote ' . $private . ' and ' . $public . '.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/OAuthServerPrune.php b/src/Console/Commands/OAuthServerPrune.php new file mode 100644 index 00000000..c9f81662 --- /dev/null +++ b/src/Console/Commands/OAuthServerPrune.php @@ -0,0 +1,36 @@ +subDays(max(0, (int) $this->option('days'))); + + $codes = OAuthAuthCode::where('expires_at', '<', $before)->delete(); + $refresh = OAuthRefreshToken::where('expires_at', '<', $before)->delete(); + $access = OAuthAccessToken::where('expires_at', '<', $before) + ->whereNotIn('id', OAuthRefreshToken::select('access_token_id')) + ->delete(); + + $this->info("Deleted {$codes} codes, {$access} access tokens and {$refresh} refresh tokens."); + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/Internal/v1/ConnectedAppController.php b/src/Http/Controllers/Internal/v1/ConnectedAppController.php new file mode 100644 index 00000000..cb06c9da --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ConnectedAppController.php @@ -0,0 +1,56 @@ +where('user_uuid', $request->user()->uuid) + ->with(['client', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->get() + ->filter(fn (OAuthGrant $grant) => $grant->client !== null) + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'app' => [ + 'name' => $grant->client->name, + 'logo_url' => $grant->client->logo_url, + 'website_url' => $grant->client->website_url, + 'official' => $grant->client->official, + 'disabled' => $grant->client->isDisabled(), + ], + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => OAuthScopes::describe($grant->scopes ?? [], $grant->company?->name), + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]) + ->values(); + + return new JsonResponse(['connected_apps' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/connected-apps/{uuid}. + */ + public function destroy(Request $request, string $uuid): JsonResponse + { + $grant = OAuthGrant::where('uuid', $uuid)->where('user_uuid', $request->user()->uuid)->firstOrFail(); + $grant->revoke(); + + return new JsonResponse(['status' => 'ok']); + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthAppController.php b/src/Http/Controllers/Internal/v1/OAuthAppController.php new file mode 100644 index 00000000..02c060b0 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthAppController.php @@ -0,0 +1,202 @@ + fn ($query) => $query->whereNull('revoked_at')]) + ->orderBy('name') + ->get(); + + return new JsonResponse(['apps' => $apps, 'scopes' => $this->scopes()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}. + */ + public function show(AdminRequest $request, string $id): JsonResponse + { + return new JsonResponse(['app' => OAuthClient::findOrFail($id), 'scopes' => $this->scopes()]); + } + + /** + * POST int/v1/oauth-server/apps. + */ + public function store(AdminRequest $request): JsonResponse + { + $attributes = $this->validated($request->all()); + $client = new OAuthClient($attributes + ['created_by_uuid' => $request->user()->uuid]); + $secret = ($attributes['confidential'] ?? true) ? $client->issueSecret() : null; + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret], 201); + } + + /** + * PUT int/v1/oauth-server/apps/{id}. + */ + public function update(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $attributes = $this->validated($request->all(), $client); + unset($attributes['confidential']); + $client->fill($attributes)->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/rotate-secret. The old secret stops working at once. + */ + public function rotateSecret(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + if (!$client->isConfidential()) { + return new JsonResponse(['errors' => ['A public app has no secret to rotate.']], 422); + } + + $secret = $client->issueSecret(); + $client->save(); + + return new JsonResponse(['app' => $client->fresh(), 'client_secret' => $secret]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/disable. Turns the app off and revokes every grant. + */ + public function disable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => now()])->save(); + + $grants = OAuthGrant::active()->where('client_id', $client->id)->pluck('uuid')->all(); + OAuthGrant::whereIn('uuid', $grants)->update(['revoked_at' => now()]); + OAuthGrant::revokeTokensFor($grants); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * POST int/v1/oauth-server/apps/{id}/enable. Users have to authorize it again. + */ + public function enable(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $client->forceFill(['disabled_at' => null])->save(); + + return new JsonResponse(['app' => $client->fresh()]); + } + + /** + * GET int/v1/oauth-server/apps/{id}/grants. + */ + public function grants(AdminRequest $request, string $id): JsonResponse + { + $client = OAuthClient::findOrFail($id); + $grants = OAuthGrant::active() + ->where('client_id', $client->id) + ->with(['user:uuid,public_id,name,email', 'company:uuid,public_id,name']) + ->latest('updated_at') + ->limit(500) + ->get() + ->map(fn (OAuthGrant $grant) => [ + 'id' => $grant->uuid, + 'user' => $grant->user ? ['id' => $grant->user->public_id, 'name' => $grant->user->name, 'email' => $grant->user->email] : null, + 'organization' => $grant->company ? ['id' => $grant->company->public_id, 'name' => $grant->company->name] : null, + 'scopes' => $grant->scopes, + 'created_at' => $grant->created_at, + 'last_used_at' => $grant->last_used_at, + ]); + + return new JsonResponse(['grants' => $grants]); + } + + /** + * DELETE int/v1/oauth-server/grants/{uuid}. + */ + public function revokeGrant(AdminRequest $request, string $uuid): JsonResponse + { + OAuthGrant::findOrFail($uuid)->revoke(); + + return new JsonResponse(['status' => 'ok']); + } + + /** + * @return array + */ + protected function scopes(): array + { + return OAuthScopes::describe(array_keys(OAuthScopes::all())); + } + + /** + * @param array $input + * + * @return array + * + * @throws ValidationException + */ + protected function validated(array $input, ?OAuthClient $client = null): array + { + $required = $client ? 'sometimes' : 'required'; + $validator = Validator::make($input, [ + 'name' => [$required, 'string', 'max:120'], + 'description' => ['nullable', 'string', 'max:500'], + 'redirect_uris' => [$required, 'array', 'min:1', 'max:20'], + 'redirect_uris.*' => ['string', 'max:2000'], + 'allowed_scopes' => [$required, 'array', 'min:1'], + 'allowed_scopes.*' => ['string'], + 'logo_url' => ['nullable', 'url', 'max:2000'], + 'website_url' => ['nullable', 'url', 'max:2000'], + 'privacy_url' => ['nullable', 'url', 'max:2000'], + 'terms_url' => ['nullable', 'url', 'max:2000'], + 'official' => ['sometimes', 'boolean'], + 'development' => ['sometimes', 'boolean'], + 'confidential' => ['sometimes', 'boolean'], + ]); + + $validator->after(function ($validator) use ($input, $client) { + $development = (bool) ($input['development'] ?? $client?->development ?? false); + foreach ((array) ($input['redirect_uris'] ?? []) as $uri) { + if (!is_string($uri) || !OAuthClient::isAcceptableRedirectUri($uri, $development)) { + $validator->errors()->add('redirect_uris', "`{$uri}` must be an https URL without a fragment (http is allowed only for 127.0.0.1 or [::1] on a development app)."); + } + } + foreach ((array) ($input['allowed_scopes'] ?? []) as $scope) { + if (!is_string($scope) || !OAuthScopes::has($scope)) { + $validator->errors()->add('allowed_scopes', "`{$scope}` is not a registered scope."); + } + } + }); + + $attributes = $validator->validate(); + if (isset($attributes['redirect_uris'])) { + $attributes['redirect_uris'] = array_values(array_unique($attributes['redirect_uris'])); + } + if (isset($attributes['allowed_scopes'])) { + $attributes['allowed_scopes'] = array_values(array_unique($attributes['allowed_scopes'])); + } + + return $attributes; + } +} diff --git a/src/Http/Controllers/Internal/v1/OAuthConsentController.php b/src/Http/Controllers/Internal/v1/OAuthConsentController.php new file mode 100644 index 00000000..f7e89834 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/OAuthConsentController.php @@ -0,0 +1,173 @@ +user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->query())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true]); + } + + $client = $authorization->client; + $organizations = $authorization->requiresOrganization() ? OAuthOrganizations::forPicker($user) : collect(); + $previous = OAuthGrant::active()->where('user_uuid', $user->uuid)->where('client_id', $client->id)->whereNotNull('company_uuid')->latest('updated_at')->first(); + $selected = $organizations->firstWhere('uuid', $previous?->company_uuid) ?? $organizations->firstWhere('uuid', $user->company_uuid); + + return new JsonResponse([ + 'client' => [ + 'id' => $client->id, + 'name' => $client->name, + 'description' => $client->description, + 'logo_url' => $client->logo_url, + 'website_url' => $client->website_url, + 'privacy_url' => $client->privacy_url, + 'terms_url' => $client->terms_url, + 'official' => $client->official, + 'redirect_host' => parse_url($authorization->redirectUri, PHP_URL_HOST), + ], + 'user' => [ + 'name' => $user->name, + 'email' => $user->email, + 'avatar_url' => $user->avatar_url, + ], + 'scopes' => $this->scopeTemplates($authorization), + 'requires_organization' => $authorization->requiresOrganization(), + 'organizations' => $organizations->map(fn ($organization) => array_diff_key($organization, ['uuid' => true]))->values(), + 'selected_organization' => $selected && $selected['can_share'] ? $selected['id'] : null, + 'skip_consent' => $this->server->canSkipConsent($authorization, $user), + ]); + } + + /** + * POST int/v1/oauth-server/authorization {...authorization request, organization}. + */ + public function approve(Request $request): JsonResponse + { + $user = $request->user(); + + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->except(['organization']))); + if ($this->server->needsReauthentication($authorization, $this->authTime($request))) { + return new JsonResponse(['reauthenticate' => true], 409); + } + + $company = $authorization->requiresOrganization() ? OAuthOrganizations::find($user, $request->input('organization')) : null; + $result = $this->server->approve($authorization, $user, $company, $this->authTime($request)); + } catch (AuthorizeError $e) { + return $this->error($e, 422); + } + + if ($result['created']) { + $this->notify($user, $authorization, $company?->name); + } + + return new JsonResponse(['redirect' => $result['redirect']]); + } + + /** + * DELETE int/v1/oauth-server/authorization {...authorization request}. + */ + public function deny(Request $request): JsonResponse + { + try { + $authorization = $this->server->validateAuthorizationRequest($this->authorizationParams($request->all())); + } catch (AuthorizeError $e) { + return $this->error($e); + } + + return new JsonResponse(['redirect' => $this->server->deny($authorization)]); + } + + /** + * Only the authorization request's own parameters, whichever way the console sent them. + * + * @param array $input + * + * @return array + */ + protected function authorizationParams(array $input): array + { + return array_intersect_key($input, array_flip([ + 'response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', + 'code_challenge', 'code_challenge_method', 'prompt', 'max_age', 'login_hint', 'ui_locales', + ])); + } + + /** + * When the console session was signed in: its Sanctum token was created after the password + * and two-factor checks passed. + */ + protected function authTime(Request $request): ?\DateTimeInterface + { + $token = method_exists($request->user(), 'currentAccessToken') ? $request->user()->currentAccessToken() : null; + + return $token?->created_at ?? null; + } + + /** + * Consent lines with `{organization}` left in, so the console can fill in the one picked. + * + * @return array + */ + protected function scopeTemplates(ValidatedAuthorization $authorization): array + { + return array_map(fn ($scope) => ['scope' => $scope] + array_intersect_key(OAuthScopes::get($scope), ['description' => true, 'organization' => true]), $authorization->scopes); + } + + protected function error(AuthorizeError $e, int $status = 400): JsonResponse + { + return new JsonResponse([ + 'error' => $e->error, + 'error_description' => $e->description, + 'redirect' => $e->redirectUrl($this->server->issuer()), + ], $status); + } + + protected function notify(User $user, ValidatedAuthorization $authorization, ?string $organizationName): void + { + try { + $descriptions = array_column(OAuthScopes::describe($authorization->scopes, $organizationName), 'description'); + $user->notify(new OAuthAppAuthorized($authorization->client->name, $organizationName, $descriptions)); + } catch (\Throwable $e) { + Log::warning('[Fleetbase] Could not send the app authorized notice.', ['error' => $e->getMessage()]); + } + } +} diff --git a/src/Http/Controllers/Internal/v1/ReviewAccessController.php b/src/Http/Controllers/Internal/v1/ReviewAccessController.php new file mode 100644 index 00000000..17721dfb --- /dev/null +++ b/src/Http/Controllers/Internal/v1/ReviewAccessController.php @@ -0,0 +1,104 @@ +subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'view_permission', $request)) { + return response()->authorizationError(); + } + + return response()->json(['review_access' => AppReview::status($subject)]); + } + + public function update(string $type, string $id, Request $request): JsonResponse + { + $subject = $this->subject($type, $id, $request); + if (!$subject) { + return response()->error('Not found.', 404); + } + + if (!$this->allowed($type, 'permission', $request)) { + return response()->authorizationError(); + } + + $request->validate([ + 'enabled' => 'sometimes|boolean', + 'regenerate_code' => 'sometimes|boolean', + ]); + + $enabled = $request->has('enabled') ? $request->boolean('enabled') : null; + + [, $code] = AppReview::update($subject, $enabled, $request->boolean('regenerate_code'), $request->session()->get('user')); + + $status = AppReview::status($subject); + if ($code !== null) { + $status['code'] = $code; + } + + return response()->json(['review_access' => $status]); + } + + protected function subject(string $type, string $id, Request $request): ?Model + { + return AppReview::resolveSubject($type, $id, $request->session()->get('company')); + } + + /** + * Administrators always may; anyone else needs the permission the type registered, when + * it registered one. + */ + protected function allowed(string $type, string $key, Request $request): bool + { + $permission = AppReview::definition($type)[$key] ?? null; + if (!$permission) { + return true; + } + + $user = Auth::getUserFromSession($request); + if (!$user) { + return false; + } + + if (!$user->isNotAdmin()) { + return true; + } + + // The permission or a wildcard over it, the way AuthorizationGuard reads them. + $names = [$permission]; + $parts = explode(' ', $permission); + if (count($parts) === 3) { + $names[] = implode(' ', [$parts[0], '*', $parts[2]]); + $names[] = implode(' ', [$parts[0], '*']); + } + + $permissions = Permission::findByNames($names); + + return $permissions->isNotEmpty() && !$user->doesntHavePermissions($permissions); + } +} diff --git a/src/Http/Controllers/OAuthServer/AuthorizationController.php b/src/Http/Controllers/OAuthServer/AuthorizationController.php new file mode 100644 index 00000000..b25e58d8 --- /dev/null +++ b/src/Http/Controllers/OAuthServer/AuthorizationController.php @@ -0,0 +1,38 @@ +validateAuthorizationRequest($request->query()); + } catch (AuthorizeError $e) { + if ($e->canRedirect()) { + return new RedirectResponse($e->redirectUrl($server->issuer())); + } + + return new RedirectResponse(Utils::consoleUrl($consentPath, ['error' => $e->error, 'error_description' => $e->description])); + } + + return new RedirectResponse(Utils::consoleUrl($consentPath, $request->query())); + } +} diff --git a/src/Http/Controllers/OAuthServer/DiscoveryController.php b/src/Http/Controllers/OAuthServer/DiscoveryController.php new file mode 100644 index 00000000..b5f8d7cd --- /dev/null +++ b/src/Http/Controllers/OAuthServer/DiscoveryController.php @@ -0,0 +1,23 @@ +discovery(), 200, ['Cache-Control' => 'public, max-age=3600']); + } + + public function jwks(OAuthServer $server): JsonResponse + { + return new JsonResponse(['keys' => [$server->keys()->jwk()]], 200, ['Cache-Control' => 'public, max-age=3600']); + } +} diff --git a/src/Http/Controllers/OAuthServer/ResourceController.php b/src/Http/Controllers/OAuthServer/ResourceController.php new file mode 100644 index 00000000..77ee1cc2 --- /dev/null +++ b/src/Http/Controllers/OAuthServer/ResourceController.php @@ -0,0 +1,99 @@ +user, $token->scopes ?? [], $token->company), 200, ['Cache-Control' => 'no-store']); + } + + /** + * GET /oauth/v1/organization (scope: organization:read). + */ + public function organization(Request $request): JsonResponse + { + $company = AuthenticateOAuthToken::company($request); + + return new JsonResponse(['organization' => static::organizationPayload($company)], 200, ['Cache-Control' => 'no-store']); + } + + /** + * GET|PUT|DELETE /oauth/v1/review-access/{type}/{id}. + * + * Turns App Review sign-in on or off for something the organization owns (a store, a network, + * the Navigator app). The scope that covers the type is checked here, because extensions + * register which scope covers which of their types. + * + * - GET: the status; never the code. + * - PUT: turn on. The 6-digit code is in the response only when this call created it (the + * first time, or with `regenerate_code`); it can never be read back. + * - DELETE: turn off. The code is kept, so turning on again restores it. + */ + public function reviewAccess(Request $request, string $type, string $id): JsonResponse + { + $scope = OAuthScopes::scopeForReviewAccessType($type); + $token = AuthenticateOAuthToken::token($request); + if (!$scope || AppReview::definition($type) === null) { + return new JsonResponse(['error' => 'not_found', 'error_description' => "Unknown review access type `{$type}`."], 404); + } + if (!$token->hasScope($scope) || !$token->client->allowsScope($scope)) { + return (new \Fleetbase\Auth\OAuthServer\Exceptions\TokenError('insufficient_scope', "This request needs the `{$scope}` scope.", 403, $scope))->toResponse(); + } + + $subject = AppReview::resolveSubject($type, $id, $token->company_uuid); + if (!$subject) { + return new JsonResponse(['error' => 'not_found', 'error_description' => 'Nothing with that id in this organization.'], 404); + } + + if ($request->isMethod('GET')) { + return new JsonResponse(['review_access' => AppReview::status($subject)], 200, ['Cache-Control' => 'no-store']); + } + + $enabled = !$request->isMethod('DELETE'); + [, $plainCode] = AppReview::update($subject, $enabled, $enabled && $request->boolean('regenerate_code'), $token->user_uuid); + $status = AppReview::status($subject); + if ($plainCode !== null) { + $status['code'] = $plainCode; + } + + return new JsonResponse(['review_access' => $status], 200, ['Cache-Control' => 'no-store']); + } + + /** + * @return array + */ + public static function organizationPayload(Company $company): array + { + return [ + 'id' => $company->public_id, + 'name' => $company->name, + 'logo_url' => $company->logo_url, + 'currency' => $company->currency, + 'country' => $company->country, + 'timezone' => $company->timezone, + 'website_url' => $company->website_url, + ]; + } +} diff --git a/src/Http/Controllers/OAuthServer/TokenController.php b/src/Http/Controllers/OAuthServer/TokenController.php new file mode 100644 index 00000000..cfa1d49f --- /dev/null +++ b/src/Http/Controllers/OAuthServer/TokenController.php @@ -0,0 +1,23 @@ +respondToTokenRequest($request); + } + + public function revoke(Request $request, OAuthServer $server) + { + return $server->revoke($request); + } +} diff --git a/src/Http/Middleware/AuthenticateOAuthToken.php b/src/Http/Middleware/AuthenticateOAuthToken.php new file mode 100644 index 00000000..d43b0430 --- /dev/null +++ b/src/Http/Middleware/AuthenticateOAuthToken.php @@ -0,0 +1,65 @@ +middleware(AuthenticateOAuthToken::class . ':storefront:read') + * + * Every listed scope is required. The token, its user and its organization are put on the + * request; controllers read them with AuthenticateOAuthToken::token() / ::company(). Nothing is + * put in the session: these requests never act as the user's console session. + */ +class AuthenticateOAuthToken +{ + public const ATTRIBUTE = 'oauth_access_token'; + + public function __construct(protected OAuthServer $server) + { + } + + public function handle(Request $request, \Closure $next, string ...$scopes) + { + try { + $token = $this->server->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e->toResponse(); + } + + $request->attributes->set(static::ATTRIBUTE, $token); + $request->setUserResolver(fn () => $token->user); + + return $next($request); + } + + public static function token(Request $request): OAuthAccessToken + { + $token = $request->attributes->get(static::ATTRIBUTE); + if (!$token instanceof OAuthAccessToken) { + throw new \LogicException('This route is missing the AuthenticateOAuthToken middleware.'); + } + + return $token; + } + + /** + * The organization the token is bound to. Only call it on routes that require an + * organization scope, which guarantees there is one. + */ + public static function company(Request $request): Company + { + $company = static::token($request)->company; + if (!$company) { + throw new \LogicException('This token is not bound to an organization; require an organization scope on the route.'); + } + + return $company; + } +} diff --git a/src/Models/OAuthAccessToken.php b/src/Models/OAuthAccessToken.php new file mode 100644 index 00000000..2b4f8636 --- /dev/null +++ b/src/Models/OAuthAccessToken.php @@ -0,0 +1,67 @@ + 'array', + 'revoked' => 'boolean', + 'expires_at' => 'datetime', + ]; + + public function grant(): BelongsTo + { + return $this->belongsTo(OAuthGrant::class, 'grant_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes ?? [], true); + } + + public function isUsable(): bool + { + return !$this->revoked && $this->expires_at && $this->expires_at->isFuture(); + } +} diff --git a/src/Models/OAuthAuthCode.php b/src/Models/OAuthAuthCode.php new file mode 100644 index 00000000..afd63ed6 --- /dev/null +++ b/src/Models/OAuthAuthCode.php @@ -0,0 +1,37 @@ + 'array', + 'revoked' => 'boolean', + 'auth_time' => 'datetime', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/src/Models/OAuthClient.php b/src/Models/OAuthClient.php new file mode 100644 index 00000000..7f03b7ea --- /dev/null +++ b/src/Models/OAuthClient.php @@ -0,0 +1,246 @@ + + */ + protected $fillable = [ + 'name', + 'description', + 'redirect_uris', + 'allowed_scopes', + 'logo_url', + 'website_url', + 'privacy_url', + 'terms_url', + 'official', + 'development', + 'created_by_uuid', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['secret']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'redirect_uris' => 'array', + 'allowed_scopes' => 'array', + 'official' => 'boolean', + 'development' => 'boolean', + 'secret_rotated_at' => 'datetime', + 'disabled_at' => 'datetime', + ]; + + /** + * Dynamic attributes appended to the model's JSON form. + * + * @var array + */ + protected $appends = ['confidential']; + + /** + * Generate the client id on create. + */ + protected static function booted(): void + { + static::creating(function (self $client) { + if (empty($client->id)) { + $client->id = (string) Str::uuid(); + } + }); + } + + /** + * The grants users have given this client. + */ + public function grants(): HasMany + { + return $this->hasMany(OAuthGrant::class, 'client_id', 'id'); + } + + /** + * Whether the client authenticates with a secret. + */ + public function isConfidential(): bool + { + return !empty($this->secret); + } + + /** + * Accessor for `confidential` in the JSON form. + */ + public function getConfidentialAttribute(): bool + { + return $this->isConfidential(); + } + + /** + * Whether an admin turned the client off. + */ + public function isDisabled(): bool + { + return $this->disabled_at !== null; + } + + /** + * Generate, hash and store a new secret, returning the plain value. + * + * The caller shows the plain value once; it can never be read back. + */ + public function issueSecret(): string + { + $plain = Str::random(64); + $this->secret = password_hash($plain, PASSWORD_BCRYPT); + $this->secret_rotated_at = now(); + + return $plain; + } + + /** + * Check a presented secret against the stored hash. + */ + public function verifySecret(?string $plain): bool + { + if (!$this->isConfidential() || !is_string($plain) || $plain === '') { + return false; + } + + return password_verify($plain, $this->secret); + } + + /** + * Whether the client was registered for this scope. + */ + public function allowsScope(string $scope): bool + { + return in_array($scope, $this->allowed_scopes ?? [], true); + } + + /** + * Whether a redirect URI is registered for this client. + * + * Matching is exact: scheme, host, port, path and query. The one exception is a development + * client using a loopback address, where native-app guidance (RFC 8252 §7.3) says the port + * must be allowed to vary. + */ + public function allowsRedirectUri(?string $uri): bool + { + if (!is_string($uri) || $uri === '') { + return false; + } + + $registered = $this->redirect_uris ?? []; + if (in_array($uri, $registered, true)) { + return true; + } + + if (!$this->development || !static::isLoopbackUri($uri)) { + return false; + } + + $withoutPort = static::withoutPort($uri); + foreach ($registered as $candidate) { + if (static::isLoopbackUri($candidate) && static::withoutPort($candidate) === $withoutPort) { + return true; + } + } + + return false; + } + + /** + * Whether a URI is an acceptable redirect URI to register. + * + * https everywhere; http only for loopback addresses; no fragments (RFC 6749 §3.1.2). + */ + public static function isAcceptableRedirectUri(string $uri, bool $development = false): bool + { + $parts = parse_url($uri); + if ($parts === false || empty($parts['scheme']) || empty($parts['host']) || isset($parts['fragment'])) { + return false; + } + + $scheme = strtolower($parts['scheme']); + if ($scheme === 'https') { + return true; + } + + return $scheme === 'http' && static::isLoopbackUri($uri) && $development; + } + + /** + * Whether a URI points at the loopback interface over http. + */ + public static function isLoopbackUri(string $uri): bool + { + $parts = parse_url($uri); + if ($parts === false || strtolower($parts['scheme'] ?? '') !== 'http') { + return false; + } + + return in_array(strtolower($parts['host'] ?? ''), ['127.0.0.1', '[::1]', 'localhost'], true); + } + + /** + * The URI with its port removed, for loopback comparison. + */ + protected static function withoutPort(string $uri): string + { + return preg_replace('#^(http://[^/:]+|http://\[::1\]):\d+#i', '$1', $uri); + } +} diff --git a/src/Models/OAuthGrant.php b/src/Models/OAuthGrant.php new file mode 100644 index 00000000..781e9e97 --- /dev/null +++ b/src/Models/OAuthGrant.php @@ -0,0 +1,167 @@ + + */ + protected $fillable = ['uuid', 'user_uuid', 'client_id', 'company_uuid', 'scopes', 'last_used_at', 'revoked_at']; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = ['id']; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'scopes' => 'array', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** + * Generate the uuid on create. + */ + protected static function booted(): void + { + static::creating(function (self $grant) { + if (empty($grant->uuid)) { + $grant->uuid = (string) Str::uuid(); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_uuid', 'uuid'); + } + + public function client(): BelongsTo + { + return $this->belongsTo(OAuthClient::class, 'client_id', 'id'); + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_uuid', 'uuid'); + } + + /** + * Grants that haven't been revoked. + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('revoked_at'); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } + + /** + * Revoke the grant and every code and token issued under it. + */ + public function revoke(): void + { + $connection = $this->getConnectionName(); + + DB::connection($connection)->transaction(function () use ($connection) { + if (!$this->isRevoked()) { + $this->revoked_at = now(); + $this->save(); + } + + static::revokeTokensFor([$this->uuid], $connection); + }); + } + + /** + * Revoke every code and token under the given grants. + * + * @param array $grantUuids + */ + public static function revokeTokensFor(array $grantUuids, ?string $connection = null): void + { + if (empty($grantUuids)) { + return; + } + + $db = DB::connection($connection ?? 'mysql'); + + $db->table('oauth_auth_codes')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + $db->table('oauth_access_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true, 'updated_at' => now()]); + $db->table('oauth_refresh_tokens')->whereIn('grant_uuid', $grantUuids)->update(['revoked' => true]); + } + + /** + * Record that the grant was used, at most once a minute so busy clients don't write per call. + */ + public function touchLastUsed(): void + { + if ($this->last_used_at && $this->last_used_at->gt(now()->subMinute())) { + return; + } + + $this->forceFill(['last_used_at' => now()])->saveQuietly(); + } +} diff --git a/src/Models/OAuthRefreshToken.php b/src/Models/OAuthRefreshToken.php new file mode 100644 index 00000000..a013d74c --- /dev/null +++ b/src/Models/OAuthRefreshToken.php @@ -0,0 +1,35 @@ + 'boolean', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/src/Models/ReviewAccess.php b/src/Models/ReviewAccess.php new file mode 100644 index 00000000..14f280a4 --- /dev/null +++ b/src/Models/ReviewAccess.php @@ -0,0 +1,66 @@ + 'boolean', + 'code_generated_at' => 'datetime', + 'enabled_at' => 'datetime', + ]; + + /** + * Turned on and holding a code: the only state in which a reviewer can sign in. + */ + public function isUsable(): bool + { + return $this->enabled && $this->hasCode(); + } + + public function hasCode(): bool + { + return filled($this->code); + } + + /** + * Replace the code with the given one, stored hashed. + */ + public function setPlainCode(string $code): void + { + $this->code = Hash::make($code); + $this->code_generated_at = now(); + } + + public function checkCode(string $code): bool + { + return $this->hasCode() && $code !== '' && Hash::check($code, $this->code); + } +} diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index fed8e79d..057776e1 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; use Fleetbase\Services\SmsService; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsLedger; @@ -396,10 +397,15 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $destination = data_get($options, 'phone') ?: data_get($subject, 'phone'); $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + // A number reserved for fiction (an App Review number) is never texted: the code is + // saved as usual, but nothing is reserved or sent. App reviewers sign in with the code + // their app's owner set up instead (Fleetbase\Support\AppReview). + $fictional = $destination && AppReview::isFictionalPhone((string) $destination); + // Ask the guard BEFORE the code row exists: a refused send must leave nothing // behind, and the budgets must be counted even when the provider later fails. $reservation = null; - if ($destination) { + if ($destination && !$fictional) { $reservation = SmsGuard::make()->reserve(SmsContext::resolve((string) $destination, [ 'purpose' => $for, 'company_uuid' => $companyUuid, @@ -476,7 +482,7 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $provider = data_get($options, 'provider'); // Send SMS using SmsService with automatic provider routing - if ($destination) { + if ($destination && !$fictional) { try { $smsService = new SmsService(); $smsService->send($destination, $message, $smsOptions, $provider); diff --git a/src/Notifications/OAuthAppAuthorized.php b/src/Notifications/OAuthAppAuthorized.php new file mode 100644 index 00000000..76456adb --- /dev/null +++ b/src/Notifications/OAuthAppAuthorized.php @@ -0,0 +1,81 @@ + $scopeDescriptions + */ + public function __construct( + public string $appName, + public ?string $organizationName, + public array $scopeDescriptions, + ) { + $this->authorizedAt = now()->toDayDateTimeString() . ' UTC'; + } + + /** + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * @return MailMessage + */ + public function toMail($notifiable) + { + $app = config('app.name'); + $subject = $this->organizationName + ? $this->appName . ' can now access ' . $this->organizationName + : $this->appName . ' can now sign you in with ' . $app; + + $message = (new MailMessage()) + ->subject($subject) + ->greeting('Hello, ' . ($notifiable->name ?? 'there')) + ->line($this->organizationName + ? 'You gave ' . $this->appName . ' access to ' . $this->organizationName . ' on ' . $app . '. It can:' + : 'You signed in to ' . $this->appName . ' with your ' . $app . ' account. It can:'); + + foreach ($this->scopeDescriptions as $description) { + $message->line('• ' . $description); + } + + return $message + ->line('Authorized: ' . $this->authorizedAt) + ->action('Review connected apps', Utils::consoleUrl('account/auth')) + ->line('If this was not you, remove the app from your connected apps and change your password straight away.'); + } + + /** + * @return array + */ + public function toArray($notifiable) + { + return [ + 'app' => $this->appName, + 'organization' => $this->organizationName, + 'scopes' => $this->scopeDescriptions, + 'authorized_at' => $this->authorizedAt, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4abaaaa3..b601364c 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -107,6 +107,9 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\SmsGuardPause::class, \Fleetbase\Console\Commands\SmsGuardUnpause::class, \Fleetbase\Console\Commands\SmsLedgerPrune::class, + \Fleetbase\Console\Commands\OAuthServerKeys::class, + \Fleetbase\Console\Commands\OAuthServerClient::class, + \Fleetbase\Console\Commands\OAuthServerPrune::class, ]; /** @@ -153,6 +156,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); $this->mergeConfigFrom(__DIR__ . '/../../config/oauth.php', 'oauth'); + $this->mergeConfigFrom(__DIR__ . '/../../config/oauth-server.php', 'oauth-server'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { @@ -211,6 +215,7 @@ public function boot() // are high-churn and short-lived; the model keeps a day's grace past expiry so a // support investigation can still tell an expired token from one that never existed. $schedule->command('model:prune', ['--model' => \Fleetbase\Models\OAuthState::class])->hourly(); + $schedule->command('oauth-server:prune')->daily(); $schedule->command('telemetry:ping')->daily(); $schedule->job(new \Fleetbase\Jobs\MaterializeSchedulesJob())->dailyAt('01:00')->name('materialize-schedules')->withoutOverlapping(); // Keep sandbox users/companies in sync with production so that @@ -230,6 +235,7 @@ public function boot() $this->registerTransformersFrom(); $this->registerMiddleware(); $this->registerNotifications(); + \Fleetbase\Auth\OAuthServer\OAuthScopes::registerDefaults(); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); $this->loadViewsFrom(__DIR__ . '/../../views', 'fleetbase'); diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index c9c3848c..7f5d9b30 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Services; +use Fleetbase\Support\AppReview; use Fleetbase\Support\Sms\SmsContext; use Fleetbase\Support\Sms\SmsGuard; use Fleetbase\Support\Sms\SmsReservation; @@ -70,6 +71,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro // Normalize phone number $normalizedPhone = $this->normalizePhoneNumber($to); + // A number reserved for fiction (the App Review numbers among them) can never reach + // anyone: answer as sent without calling a provider or counting it. + if (AppReview::isFictionalPhone($normalizedPhone)) { + Log::info('SMS to a fictional number skipped', ['to' => $to]); + + return ['success' => true, 'skipped' => true, 'provider' => null, 'message_id' => null]; + } + // Every send passes the guard before a provider is called. A caller that already // reserved the send (VerificationCode) hands its reservation over so it counts once. $reservation = $options['sms_reservation'] ?? null; diff --git a/src/Support/AppReview.php b/src/Support/AppReview.php new file mode 100644 index 00000000..237d833e --- /dev/null +++ b/src/Support/AppReview.php @@ -0,0 +1,381 @@ + Store::class, + * 'phone' => '+12025550143', + * 'permission' => 'storefront manage-app-review store', + * ]); + * + * Self-hosted installs can still allow review accounts instance-wide from env config; see + * configuredAccountMatches(). + */ +class AppReview +{ + /** + * Digits in a generated code. Fleetbase's apps take six-digit codes. + */ + public const CODE_LENGTH = 6; + + /** + * North American numbers with the 555 exchange and a line from 0100 to 0199 are reserved + * for fiction: they can never reach a real person. + */ + public const FICTIONAL_PHONE_PATTERN = '/^\+1[2-9]\d{2}55501\d{2}$/'; + + /** + * @var array, phone: string, name: string, permission: ?string, view_permission: ?string, resolve: ?callable}> + */ + protected static array $types = []; + + /** + * Register a type of subject an app signs reviewers in to. + * + * - `model`: the subject's model class (required) + * - `phone`: the review number, E.164 from the fictional range (required). Types whose + * accounts are the same kind of user may share one (a store and a network); types + * whose users differ (customers, drivers) need their own, since users are found by phone. + * - `name`: what to call the app in messages, e.g. "Storefront" + * - `permission`: the permission needed to change access; `view_permission` to read it + * - `resolve`: callable(string $id, ?string $companyUuid): ?Model finding the subject a + * Console user may manage; by default the model by uuid or public_id in the company + */ + public static function register(string $type, array $definition): void + { + $model = $definition['model'] ?? null; + $phone = static::normalizePhone($definition['phone'] ?? null); + + if (!is_string($model) || !class_exists($model)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a model class."); + } + + if (!static::isFictionalPhone($phone)) { + throw new \InvalidArgumentException("App Review type [{$type}] needs a review number from the fictional range +1 NXX 555 0100-0199."); + } + + static::$types[$type] = [ + 'type' => $type, + 'model' => $model, + 'phone' => $phone, + 'name' => $definition['name'] ?? $type, + 'permission' => $definition['permission'] ?? null, + 'view_permission' => $definition['view_permission'] ?? null, + 'resolve' => $definition['resolve'] ?? null, + ]; + } + + public static function forget(string $type): void + { + unset(static::$types[$type]); + } + + public static function types(): array + { + return static::$types; + } + + public static function definition(string $type): ?array + { + return static::$types[$type] ?? null; + } + + /** + * The registered type of a subject: the first type whose model it is. + */ + public static function typeOf(?Model $subject): ?string + { + if (!$subject) { + return null; + } + + foreach (static::$types as $type => $definition) { + if ($subject instanceof $definition['model']) { + return $type; + } + } + + return null; + } + + public static function phoneFor(string $type): ?string + { + return static::$types[$type]['phone'] ?? null; + } + + /** + * Whether a number is in the range reserved for fiction, however it is formatted. + */ + public static function isFictionalPhone(?string $phone): bool + { + $phone = static::normalizePhone($phone); + + return $phone !== null && preg_match(static::FICTIONAL_PHONE_PATTERN, $phone) === 1; + } + + /** + * Whether an identity is a registered review number: the given type's, or any type's. + */ + public static function isReviewPhone(?string $identity, ?string $type = null): bool + { + $phone = static::normalizePhone($identity); + if ($phone === null) { + return false; + } + + if ($type !== null) { + return static::phoneFor($type) === $phone; + } + + foreach (static::$types as $definition) { + if ($definition['phone'] === $phone) { + return true; + } + } + + return false; + } + + /** + * `+12025550143` as `+1 202 555 0143`. + */ + public static function formatPhone(?string $phone): ?string + { + $phone = static::normalizePhone($phone); + if ($phone === null || !preg_match('/^\+1(\d{3})(\d{3})(\d{4})$/', $phone, $parts)) { + return $phone; + } + + return "+1 {$parts[1]} {$parts[2]} {$parts[3]}"; + } + + /** + * The subject of a type a Console user in this company may manage, by uuid or public id. + */ + public static function resolveSubject(string $type, string $id, ?string $companyUuid): ?Model + { + $definition = static::definition($type); + if (!$definition) { + return null; + } + + if (is_callable($definition['resolve'])) { + return ($definition['resolve'])($id, $companyUuid); + } + + $model = $definition['model']; + $query = $model::query()->where(function ($query) use ($id, $model) { + $query->where('uuid', $id); + if (static::hasColumn($model, 'public_id')) { + $query->orWhere('public_id', $id); + } + }); + + if (is_a($model, Company::class, true)) { + return $companyUuid ? $query->where('uuid', $companyUuid)->first() : null; + } + + return $companyUuid ? $query->where('company_uuid', $companyUuid)->first() : null; + } + + /** + * The App Review access record of a subject, if it has one. + */ + public static function accessFor(?Model $subject): ?ReviewAccess + { + $type = static::typeOf($subject); + if (!$type || blank($subject->uuid)) { + return null; + } + + try { + return ReviewAccess::where('subject_type', $type)->where('subject_uuid', $subject->uuid)->first(); + } catch (\Throwable $e) { + // Before the migration has run there is no access, not an error. + return null; + } + } + + public static function isEnabledFor(?Model $subject): bool + { + return (bool) static::accessFor($subject)?->isUsable(); + } + + /** + * Whether a code signs the subject type's review number in to this subject: its access is + * on and the code is the one it generated. Another subject's code never matches. + */ + public static function acceptsCode(?Model $subject, ?string $identity, mixed $code): bool + { + $type = static::typeOf($subject); + if (!$type || !static::isReviewPhone($identity, $type) || blank($code) || !is_scalar($code)) { + return false; + } + + $access = static::accessFor($subject); + if (!$access || !$access->isUsable() || !$access->checkCode((string) $code)) { + return false; + } + + Log::info('[Fleetbase] App Review sign-in accepted.', [ + 'type' => $type, + 'subject' => $subject->public_id ?? $subject->uuid, + ]); + + return true; + } + + /** + * Change a subject's access. Turning it on without a code generates one, so it is never + * on with nothing to sign in with; `regenerate` replaces the code. Turning it off keeps + * the code, so turning it back on restores the same one. + * + * @return array{0: ReviewAccess, 1: ?string} the access and the new plain code, if one was generated + */ + public static function update(Model $subject, ?bool $enabled, bool $regenerate = false, ?string $updatedByUuid = null): array + { + $type = static::typeOf($subject); + if (!$type) { + throw new \InvalidArgumentException('No App Review type is registered for ' . get_class($subject) . '.'); + } + + $access = static::accessFor($subject) ?? new ReviewAccess([ + 'company_uuid' => $subject instanceof Company ? $subject->uuid : $subject->company_uuid, + 'subject_type' => $type, + 'subject_uuid' => $subject->uuid, + 'enabled' => false, + ]); + + if ($enabled !== null) { + if ($enabled && !$access->enabled) { + $access->enabled_at = now(); + } + $access->enabled = $enabled; + } + + $code = null; + if ($regenerate || ($access->enabled && !$access->hasCode())) { + $code = static::generateCode(); + $access->setPlainCode($code); + } + + $access->updated_by_uuid = $updatedByUuid; + $access->save(); + + return [$access, $code]; + } + + /** + * What the Console and the API show about a subject's access. Never the code or its hash; + * a caller adds `code` only to the response that generated it. + */ + public static function status(Model $subject): array + { + $type = static::typeOf($subject); + $access = static::accessFor($subject); + $phone = $type ? static::phoneFor($type) : null; + + return [ + 'type' => $type, + 'enabled' => (bool) $access?->enabled, + 'phone' => $phone, + 'phone_display' => static::formatPhone($phone), + 'has_code' => (bool) $access?->hasCode(), + 'code_generated_at' => $access?->code_generated_at?->toIso8601String(), + 'enabled_at' => $access?->enabled_at?->toIso8601String(), + ]; + } + + public static function generateCode(): string + { + return str_pad((string) random_int(0, (10 ** static::CODE_LENGTH) - 1), static::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Whether an identity is listed as an instance-wide review account in env config (for + * self-hosted installs) and a code is configured for those accounts. + * + * @param string $codeKey config key holding the code + * @param string $accountsKey config key holding the listed identities + */ + public static function isConfiguredAccount(string $codeKey, string $accountsKey, ?string $identity): bool + { + $configuredCode = config($codeKey); + + // `=== null || === ''` rather than blank(): a configured code of "0" is still a code. + if ($configuredCode === null || $configuredCode === '' || $identity === null || trim($identity) === '') { + return false; + } + + $accounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config($accountsKey, []) + ); + + return in_array(strtolower(trim($identity)), $accounts, true); + } + + /** + * Whether a code is the env-configured code for a listed review account. Only listed + * identities are accepted, so knowing the code is not enough to sign in as anyone else, + * and the comparison is constant-time. + */ + public static function configuredAccountMatches(string $codeKey, string $accountsKey, ?string $identity, mixed $code, string $channel = 'app'): bool + { + if ($code === null || $code === '' || !is_scalar($code) || !static::isConfiguredAccount($codeKey, $accountsKey, $identity)) { + return false; + } + + if (!hash_equals((string) config($codeKey), (string) $code)) { + return false; + } + + Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [ + 'channel' => $channel, + 'identity' => $identity, + ]); + + return true; + } + + /** + * Digits with a leading `+`, or null for nothing. + */ + protected static function normalizePhone(?string $phone): ?string + { + if ($phone === null) { + return null; + } + + $digits = preg_replace('/\D+/', '', $phone); + + return $digits === '' ? null : '+' . $digits; + } + + protected static function hasColumn(string $model, string $column): bool + { + try { + $instance = new $model(); + + return $instance->getConnection()->getSchemaBuilder()->hasColumn($instance->getTable(), $column); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/routes.php b/src/routes.php index 25ffc43b..4a411134 100644 --- a/src/routes.php +++ b/src/routes.php @@ -23,6 +23,37 @@ function ($router) { $router->get('/', 'Controller@hello'); + /* + |-------------------------------------------------------------------------- + | OAuth 2.0 / OpenID Connect provider ("Continue with Fleetbase") + |-------------------------------------------------------------------------- + | + | Apps sign people in with their Fleetbase account and, with consent, act on + | one organization. The consent screen itself is in the console, backed by + | the int/v1/oauth-server routes below. + */ + if (config('oauth-server.enabled', true)) { + $router->get('.well-known/openid-configuration', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->get('.well-known/oauth-authorization-server', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'configuration']); + $router->prefix('oauth')->group(function ($router) { + $router->get('authorize', [Fleetbase\Http\Controllers\OAuthServer\AuthorizationController::class, 'authorize']); + $router->get('jwks', [Fleetbase\Http\Controllers\OAuthServer\DiscoveryController::class, 'jwks']); + $router->middleware(['throttle:600,1'])->group(function ($router) { + $router->post('token', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'token']); + $router->post('revoke', [Fleetbase\Http\Controllers\OAuthServer\TokenController::class, 'revoke']); + }); + $router->middleware(['throttle:600,1'])->group(function ($router) { + $router->match(['GET', 'POST'], 'userinfo', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'userinfo']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':openid'); + $router->get('v1/organization', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'organization']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class . ':organization:read'); + // The scope is checked in the controller: each extension says which scope covers its types. + $router->match(['GET', 'PUT', 'DELETE'], 'v1/review-access/{type}/{id}', [Fleetbase\Http\Controllers\OAuthServer\ResourceController::class, 'reviewAccess']) + ->middleware(Fleetbase\Http\Middleware\AuthenticateOAuthToken::class); + }); + }); + } + /* |-------------------------------------------------------------------------- | Public/Consumable Routes @@ -204,6 +235,27 @@ function ($router) { function ($router) { // Realtime socket token for the signed-in console user. $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); + // App Review access for whatever an extension registered (stores, Navigator, ...). + $router->get('review-access/{type}/{id}', 'ReviewAccessController@show'); + $router->put('review-access/{type}/{id}', 'ReviewAccessController@update'); + // "Continue with Fleetbase": the console's consent screen, Connected apps, + // and Admin → OAuth Apps (AdminRequest checks for a system admin). + $router->group(['prefix' => 'oauth-server'], function ($router) { + $router->get('authorization', 'OAuthConsentController@show'); + $router->post('authorization', 'OAuthConsentController@approve'); + $router->delete('authorization', 'OAuthConsentController@deny'); + $router->get('connected-apps', 'ConnectedAppController@index'); + $router->delete('connected-apps/{uuid}', 'ConnectedAppController@destroy'); + $router->get('apps', 'OAuthAppController@index'); + $router->post('apps', 'OAuthAppController@store'); + $router->get('apps/{id}', 'OAuthAppController@show'); + $router->put('apps/{id}', 'OAuthAppController@update'); + $router->post('apps/{id}/rotate-secret', 'OAuthAppController@rotateSecret'); + $router->post('apps/{id}/disable', 'OAuthAppController@disable'); + $router->post('apps/{id}/enable', 'OAuthAppController@enable'); + $router->get('apps/{id}/grants', 'OAuthAppController@grants'); + $router->delete('grants/{uuid}', 'OAuthAppController@revokeGrant'); + }); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php new file mode 100644 index 00000000..bde6cfa3 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerConsoleEndpointsTest.php @@ -0,0 +1,402 @@ + 'Fleetbase', + 'fleetbase.console.host' => 'console.fleetbase.test', + 'fleetbase.console.secure' => true, + ]); + $container = Container::getInstance(); + $container->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), $container)); + Illuminate\Support\Facades\Facade::clearResolvedInstance('validator'); +}); +afterEach(fn () => oauth_test_teardown()); + +/** + * A console request from a signed-in user, with their Sanctum token's sign-in time. + */ +function oauth_console_request(string $userUuid, string $method = 'GET', array $input = [], string $signedIn = '-1 minute', string $class = Request::class): Request +{ + $user = User::where('uuid', $userUuid)->first(); + $user->withAccessToken((object) ['created_at' => Illuminate\Support\Carbon::parse($signedIn)]); + + $request = $class::create('/int/v1/oauth-server/authorization', $method, $input); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function oauth_console_consent(): OAuthConsentController +{ + return new OAuthConsentController(oauth_test_server()); +} + +/** + * Run a command with these arguments; returns [exit code, output]. + */ +function oauth_console_command(Illuminate\Console\Command $command, array $input = []): array +{ + $output = new BufferedOutput(); + $command->setLaravel(Container::getInstance()); + $command->setInput(new ArrayInput($input, $command->getDefinition())); + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + return [$command->handle(), $output->fetch()]; +} + +describe('consent screen', function () { + it('describes the request: the app, the user, the scopes and the organizations they can share', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $response = oauth_console_consent()->show(oauth_console_request('user-admin', 'GET', oauth_test_params($client, oauth_test_verifier()) + ['utm_source' => 'mail'])); + $body = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($body['client'])->toMatchArray(['id' => $client->id, 'name' => 'AppHelm', 'official' => true, 'redirect_host' => 'app.example.com']) + ->and($body['user']['email'])->toBe('admin@example.com') + ->and(array_column($body['scopes'], 'scope'))->toBe(['openid', 'profile', 'email', 'organization:read']) + ->and($body['scopes'][3]['description'])->toContain('{organization}') + ->and($body['requires_organization'])->toBeTrue() + ->and($body['organizations'])->toBe([['id' => 'company_a', 'name' => 'Acme Deliveries', 'logo_url' => $body['organizations'][0]['logo_url'], 'can_share' => true]]) + ->and($body['skip_consent'])->toBeFalse(); + }); + + it('preselects the organization the user shared with the app before', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier, [], 'user-member', 'company-b'); + + $body = oauth_console_consent()->show(oauth_console_request('user-member', 'GET', oauth_test_params($client, oauth_test_verifier())))->getData(true); + + expect($body['selected_organization'])->toBe('company_b'); + }); + + it('asks for a fresh sign-in when the app wants one', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login']); + + $show = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', $params, '-1 hour')); + $approve = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'], '-1 hour')); + + expect($show->getData(true))->toBe(['reauthenticate' => true]) + ->and($approve->getStatusCode())->toBe(409); + }); + + it('returns an error the app can receive with where to send the user, and one it cannot without', function () { + [$client] = oauth_test_client(); + + $scope = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid nope']))); + $unknown = oauth_console_consent()->show(oauth_console_request('user-owner', 'GET', ['client_id' => 'nope'])); + + expect($scope->getStatusCode())->toBe(400) + ->and($scope->getData(true)['error'])->toBe('invalid_scope') + ->and($scope->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=invalid_scope') + ->and($unknown->getData(true))->toMatchArray(['error' => 'invalid_client', 'redirect' => null]); + }); + + it('approves with the organization picked, sends the user back with a code, and tells the account holder', function () { + [$client] = oauth_test_client(); + $params = oauth_test_params($client, oauth_test_verifier()); + + $response = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + $again = oauth_console_consent()->approve(oauth_console_request('user-owner', 'POST', $params + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['redirect'])->toContain('code=') + ->and(OAuthGrant::count())->toBe(1) + ->and(OAuthGrant::first()->company_uuid)->toBe('company-a') + ->and(OAuthAuthCode::first()->auth_time)->not->toBeNull() + ->and($again->getStatusCode())->toBe(200); + }); + + it('refuses an organization the user does not administer', function () { + [$client] = oauth_test_client(); + + $response = oauth_console_consent()->approve(oauth_console_request('user-member', 'POST', oauth_test_params($client, oauth_test_verifier()) + ['organization' => 'company_a'])); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true)['error'])->toBe('access_denied') + ->and(OAuthGrant::count())->toBe(0); + }); + + it('sends a cancelled request back to the app', function () { + [$client] = oauth_test_client(); + + $deny = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', oauth_test_params($client, oauth_test_verifier()))); + $invalid = oauth_console_consent()->deny(oauth_console_request('user-owner', 'DELETE', ['client_id' => 'nope'])); + + expect($deny->getData(true)['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($invalid->getStatusCode())->toBe(400); + }); +}); + +describe('the authorization endpoint', function () { + it('sends a valid request to the console consent screen, and errors where they belong', function () { + [$client] = oauth_test_client(); + $controller = new AuthorizationController(); + $authorize = fn (array $query) => $controller->authorize(Request::create('/oauth/authorize', 'GET', $query), oauth_test_server())->getTargetUrl(); + + expect($authorize(oauth_test_params($client, oauth_test_verifier())))->toStartWith('https://console.fleetbase.test/oauth/authorize?response_type=code&client_id=' . $client->id) + ->and($authorize(oauth_test_params($client, oauth_test_verifier(), ['response_type' => 'token'])))->toStartWith(OAUTH_TEST_REDIRECT . '?error=unsupported_response_type') + ->and($authorize(['client_id' => 'nope']))->toStartWith('https://console.fleetbase.test/oauth/authorize?error=invalid_client'); + }); + + it('hands token and revocation requests to the server', function () { + [$client, $secret] = oauth_test_client(); + $controller = new TokenController(); + + $token = $controller->token(Request::create('/oauth/token', 'POST', ['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'x']), oauth_test_server()); + $revoke = $controller->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'x', 'client_id' => $client->id, 'client_secret' => $secret]), oauth_test_server()); + + expect($token->getStatusCode())->toBe(401) + ->and($revoke->getStatusCode())->toBe(200); + }); +}); + +describe('connected apps', function () { + it('lists the user\'s apps in plain words, and removes one', function () { + [$client] = oauth_test_client(['logo_url' => 'https://app.example.com/logo.png']); + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $controller = new ConnectedAppController(); + + $apps = $controller->index(oauth_console_request('user-owner'))->getData(true)['connected_apps']; + + expect($apps)->toHaveCount(1) + ->and($apps[0]['app'])->toMatchArray(['name' => 'AppHelm', 'official' => true, 'disabled' => false]) + ->and($apps[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']) + ->and(array_column($apps[0]['scopes'], 'description'))->toContain("See Acme Deliveries's name, logo, currency and country") + ->and($controller->index(oauth_console_request('user-member'))->getData(true)['connected_apps'])->toBe([]); + + expect(fn () => $controller->destroy(oauth_console_request('user-member'), $apps[0]['id']))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); + + $controller->destroy(oauth_console_request('user-owner'), $apps[0]['id']); + + expect(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAuthCode::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('admin: OAuth Apps', function () { + function oauth_admin_request(string $method = 'GET', array $input = []): AdminRequest + { + return oauth_console_request('user-owner', $method, $input, '-1 minute', AdminRequest::class); + } + + it('registers an app, showing the secret once, then edits it', function () { + $controller = new OAuthAppController(); + + $created = $controller->store(oauth_admin_request('POST', [ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT, OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'organization:read', 'openid'], + 'official' => true, + ])); + $body = $created->getData(true); + $client = OAuthClient::find($body['app']['id']); + + expect($created->getStatusCode())->toBe(201) + ->and($body['client_secret'])->toBeString() + ->and($client->verifySecret($body['client_secret']))->toBeTrue() + ->and($client->redirect_uris)->toBe([OAUTH_TEST_REDIRECT]) + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']) + ->and($client->created_by_uuid)->toBe('user-owner') + ->and($body['app'])->not->toHaveKey('secret'); + + $updated = $controller->update(oauth_admin_request('PUT', ['name' => 'AppHelm 2', 'confidential' => false]), $client->id)->getData(true); + + expect($updated['app']['name'])->toBe('AppHelm 2') + ->and($updated['app']['confidential'])->toBeTrue(); + + $listed = $controller->index(oauth_admin_request())->getData(true); + $shown = $controller->show(oauth_admin_request(), $client->id)->getData(true); + + expect($listed['apps'][0]['active_grants_count'])->toBe(0) + ->and(array_column($listed['scopes'], 'scope'))->toContain('openid') + ->and($shown['app']['id'])->toBe($client->id); + }); + + it('registers a public app without a secret', function () { + $body = (new OAuthAppController())->store(oauth_admin_request('POST', [ + 'name' => 'Native', + 'redirect_uris' => ['http://127.0.0.1:3000/cb'], + 'allowed_scopes' => ['openid'], + 'development' => true, + 'confidential' => false, + ]))->getData(true); + + expect($body['client_secret'])->toBeNull() + ->and($body['app']['confidential'])->toBeFalse() + ->and((new OAuthAppController())->rotateSecret(oauth_admin_request('POST'), $body['app']['id'])->getStatusCode())->toBe(422); + }); + + it('refuses an unsafe redirect URI or an unknown scope', function () { + $register = fn (array $input) => (new OAuthAppController())->store(oauth_admin_request('POST', $input + ['name' => 'X', 'allowed_scopes' => ['openid'], 'redirect_uris' => [OAUTH_TEST_REDIRECT]])); + + expect(fn () => $register(['redirect_uris' => ['http://app.example.com/cb']]))->toThrow(ValidationException::class) + ->and(fn () => $register(['allowed_scopes' => ['admin:all']]))->toThrow(ValidationException::class) + ->and(OAuthClient::count())->toBe(0); + }); + + it('rotates a secret, turns an app off and on, and lists and revokes who authorized it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $tokens] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $controller = new OAuthAppController(); + + $rotated = $controller->rotateSecret(oauth_admin_request('POST'), $client->id)->getData(true); + expect($client->fresh()->verifySecret($secret))->toBeFalse() + ->and($client->fresh()->verifySecret($rotated['client_secret']))->toBeTrue(); + + $grants = $controller->grants(oauth_admin_request(), $client->id)->getData(true)['grants']; + expect($grants)->toHaveCount(1) + ->and($grants[0]['user'])->toBe(['id' => 'user_owner', 'name' => 'Olive Owner', 'email' => 'owner@example.com']) + ->and($grants[0]['organization'])->toBe(['id' => 'company_a', 'name' => 'Acme Deliveries']); + + $controller->disable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeTrue() + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0) + ->and(OAuthRefreshToken::where('revoked', false)->count())->toBe(0); + + $controller->enable(oauth_admin_request('POST'), $client->id); + expect($client->fresh()->isDisabled())->toBeFalse() + ->and($tokens)->toHaveKey('access_token'); + + $verifier = oauth_test_verifier(); + oauth_test_code($client, $verifier); + $grant = OAuthGrant::active()->first(); + $controller->revokeGrant(oauth_admin_request('DELETE'), $grant->uuid); + expect($grant->fresh()->isRevoked())->toBeTrue(); + }); +}); + +describe('the account holder notice', function () { + it('names the app, the organization and what it can do', function () { + $user = User::where('uuid', 'user-owner')->first(); + $organization = new OAuthAppAuthorized('AppHelm', 'Acme Deliveries', ['See your email address']); + $signIn = new OAuthAppAuthorized('AppHelm', null, ['Sign you in with your Fleetbase account']); + $mail = $organization->toMail($user); + + expect($organization->via($user))->toBe(['mail']) + ->and($mail->subject)->toBe('AppHelm can now access Acme Deliveries') + ->and($mail->introLines)->toContain('• See your email address') + ->and($mail->actionUrl)->toBe('https://console.fleetbase.test/account/auth') + ->and($signIn->toMail($user)->subject)->toBe('AppHelm can now sign you in with Fleetbase') + ->and($organization->toArray($user))->toMatchArray(['app' => 'AppHelm', 'organization' => 'Acme Deliveries', 'scopes' => ['See your email address']]); + }); +}); + +describe('artisan', function () { + it('registers, lists and rotates apps', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), [ + 'action' => 'create', + '--name' => 'AppHelm', + '--redirect' => [OAUTH_TEST_REDIRECT], + '--scope' => ['openid', 'organization:read'], + '--official' => true, + ]); + $client = OAuthClient::first(); + + expect($code)->toBe(0) + ->and($output)->toContain('client_id: ' . $client->id) + ->and($output)->toContain('client_secret: ') + ->and($client->official)->toBeTrue() + ->and($client->allowed_scopes)->toBe(['openid', 'organization:read']); + + [$listCode, $list] = oauth_console_command(new OAuthServerClient(), ['action' => 'list']); + expect($listCode)->toBe(0)->and($list)->toContain('AppHelm')->and($list)->toContain('confidential'); + + [$rotateCode, $rotated] = oauth_console_command(new OAuthServerClient(), ['action' => 'rotate', 'id' => $client->id]); + expect($rotateCode)->toBe(0)->and($rotated)->toContain('old secret stopped working'); + }); + + it('registers a public app with every scope by default', function () { + [$code, $output] = oauth_console_command(new OAuthServerClient(), ['action' => 'create', '--name' => 'Native', '--redirect' => ['http://127.0.0.1:3000/cb'], '--public' => true, '--development' => true]); + + expect($code)->toBe(0) + ->and($output)->not->toContain('client_secret') + ->and(OAuthClient::first()->allowed_scopes)->toBe(array_keys(Fleetbase\Auth\OAuthServer\OAuthScopes::all())); + }); + + it('refuses what it cannot register or rotate', function () { + $run = fn (array $input) => oauth_console_command(new OAuthServerClient(), $input)[0]; + + expect($run(['action' => 'delete']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X']))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => ['http://app.example.com/cb']]))->toBe(1) + ->and($run(['action' => 'create', '--name' => 'X', '--redirect' => [OAUTH_TEST_REDIRECT], '--scope' => ['admin:all']]))->toBe(1) + ->and($run(['action' => 'rotate', 'id' => 'nope']))->toBe(1) + ->and(OAuthClient::count())->toBe(0); + }); + + it('writes or prints a key pair, and will not overwrite one by accident', function () { + foreach (['private', 'public'] as $type) { + if (file_exists(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type))) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + } + if (!is_dir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))) { + mkdir(dirname(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')), 0777, true); + } + + [$shownCode, $shown] = oauth_console_command(new OAuthServerKeys(), ['--show' => true]); + [$writeCode] = oauth_console_command(new OAuthServerKeys()); + [$againCode] = oauth_console_command(new OAuthServerKeys()); + [$forceCode] = oauth_console_command(new OAuthServerKeys(), ['--force' => true]); + + expect($shownCode)->toBe(0) + ->and($shown)->toContain('OAUTH_SERVER_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\n') + ->and($writeCode)->toBe(0) + ->and(file_get_contents(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath('private')))->toContain('PRIVATE KEY') + ->and($againCode)->toBe(1) + ->and($forceCode)->toBe(0); + + foreach (['private', 'public'] as $type) { + unlink(Fleetbase\Auth\OAuthServer\OAuthKeys::keyPath($type)); + } + })->skip(fn () => !function_exists('openssl_pkey_new'), 'needs openssl'); + + it('prunes codes and tokens that expired more than a week ago', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $fresh = oauth_test_code($client, oauth_test_verifier()); + + OAuthAccessToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthRefreshToken::query()->update(['expires_at' => now()->subDays(40)]); + OAuthAuthCode::where('revoked', true)->update(['expires_at' => now()->subDays(40)]); + + [$code, $output] = oauth_console_command(new OAuthServerPrune(), ['--days' => '7']); + + expect($code)->toBe(0) + ->and($output)->toContain('Deleted 1 codes, 1 access tokens and 1 refresh tokens.') + ->and(OAuthAuthCode::count())->toBe(1) + ->and($fresh)->toBeString(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php new file mode 100644 index 00000000..73c34145 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEdgeCasesTest.php @@ -0,0 +1,386 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_edge_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +describe('authorization request checks', function () { + it('refuses malformed parameters, with the error going back to the app', function () { + [$client] = oauth_test_client(); + $params = fn (array $overrides) => oauth_test_params($client, oauth_test_verifier(), $overrides); + + $cases = [ + ['scope' => ['openid']], + ['response_type' => 'token'], + ['nonce' => str_repeat('n', 256)], + ['max_age' => 'soon'], + ['code_challenge' => 'too-short'], + ['prompt' => 'none consent'], + ]; + + $errors = array_map(fn ($overrides) => oauth_edge_authorize_error(array_merge($params([]), $overrides)), $cases); + + expect(array_map(fn ($error) => $error->error, $errors))->toBe(['invalid_request', 'unsupported_response_type', 'invalid_request', 'invalid_request', 'invalid_request', 'invalid_request']) + ->and(array_filter($errors, fn ($error) => !$error->canRedirect()))->toBe([]); + }); + + it('asks for a sign-in when it cannot tell when the user signed in', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + expect($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'login'])), null))->toBeTrue() + ->and($server->needsReauthentication($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), null))->toBeFalse(); + }); + + it('sends a declined request back to the app with access_denied and the state', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + + $url = $server->deny($server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier()))); + + expect($url)->toStartWith(OAUTH_TEST_REDIRECT . '?error=access_denied') + ->and($url)->toContain('state=state-123') + ->and($url)->toContain('iss='); + }); + + it('keeps the query a redirect URI already has', function () { + $error = new AuthorizeError('access_denied', null, 'https://app.example.com/cb?tenant=1', 's'); + + expect($error->redirectUrl('https://api.fleetbase.test'))->toBe('https://app.example.com/cb?tenant=1&error=access_denied&state=s&iss=https%3A%2F%2Fapi.fleetbase.test'); + }); +}); + +describe('token endpoint', function () { + it('lets league reject a code or refresh token it cannot open', function () { + [$client, $secret] = oauth_test_client(); + + [$codeStatus, $code] = oauth_test_token(['grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => $secret, 'code' => 'garbage', 'redirect_uri' => OAUTH_TEST_REDIRECT, 'code_verifier' => oauth_test_verifier()]); + [$refreshStatus, $refresh] = oauth_test_token(['grant_type' => 'refresh_token', 'client_id' => $client->id, 'client_secret' => $secret, 'refresh_token' => 'garbage']); + + expect($codeStatus)->toBe(400) + ->and($code['error'])->toBe('invalid_request') + ->and($refreshStatus)->toBe(401) + ->and($refresh['error'])->toBe('invalid_request'); + }); + + it('refuses a code whose grant was removed before it was redeemed', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + OAuthGrant::query()->update(['revoked_at' => now()]); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code once its user is deactivated, and drops the grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('issues no ID token without openid', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'organization:read']), $verifier); + + expect($body)->not->toHaveKey('id_token') + ->and($body['scope'])->toBe('organization:read'); + }); + + it('issues no ID token for a user who has since been deleted', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid']); + + // Removing the user cascades to the grant in MySQL; here only the user row goes, so the + // response type is the one that notices. + User::where('uuid', 'user-owner')->forceDelete(); + $response = new Fleetbase\Auth\OAuthServer\IdTokenResponse(new IssueContext(), oauth_test_server()->keys(), 'https://api.fleetbase.test', new DateInterval('PT1H')); + $token = (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->getNewToken(new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true), [new ScopeEntity('openid')], 'user-owner'); + $token->setPrivateKey(oauth_test_server()->keys()->privateKey()); + $token->setIdentifier('token-1'); + $token->setExpiryDateTime(new DateTimeImmutable('+1 hour')); + $response->setAccessToken($token); + $response->setEncryptionKey(oauth_test_server()->keys()->encryptionKey()); + + $body = json_decode((string) $response->generateHttpResponse(new Nyholm\Psr7\Response())->getBody(), true); + + expect($code)->toBeString() + ->and($body)->not->toHaveKey('id_token'); + }); +}); + +describe('revocation', function () { + it('needs a token', function () { + [$client, $secret] = oauth_test_client(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(400); + }); + + it('ignores an access token signed by another key', function () { + [$client, $secret] = oauth_test_client(); + $other = OAuthKeys::generate(2048); + $config = Lcobucci\JWT\Configuration::forAsymmetricSigner(new Lcobucci\JWT\Signer\Rsa\Sha256(), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['private']), Lcobucci\JWT\Signer\Key\InMemory::plainText($other['public'])); + $forged = $config->builder()->identifiedBy('forged')->getToken($config->signer(), $config->signingKey())->toString(); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $forged, 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($response->getStatusCode())->toBe(200); + }); + + it('lets a public client revoke with its client_id alone, and refuses one that sends a secret', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + $withSecret = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => 'x'])); + $plain = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $client->id])); + + expect($withSecret->getStatusCode())->toBe(401) + ->and($plain->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('protected resources', function () { + it('refuses a token whose row is gone', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + // A token row that is gone reads as revoked to league; one that is merely unusable (an + // expiry in the past that league's clock leeway still let through) reaches the row check. + Fleetbase\Models\OAuthAccessToken::query()->update(['expires_at' => now()->subMinute()]); + + $response = (new AuthenticateOAuthToken(oauth_test_server()))->handle(oauth_test_bearer($body['access_token']), fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(401); + }); + + it('says when a route forgot the middleware or an organization scope', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => 'openid'], 'user-member', null), $verifier); + $request = oauth_test_bearer($body['access_token']); + + expect(fn () => AuthenticateOAuthToken::token(Request::create('/x')))->toThrow(LogicException::class); + + (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, fn () => null); + + expect(fn () => AuthenticateOAuthToken::company($request))->toThrow(LogicException::class) + ->and($request->user()->uuid)->toBe('user-member'); + }); +}); + +describe('repositories', function () { + it('only know registered, enabled clients and the two supported grants', function () { + [$client, $secret] = oauth_test_client(); + [$public] = oauth_test_client(['name' => 'Native'], confidential: false); + $repository = new ClientRepository(); + + expect($repository->getClientEntity('00000000-0000-0000-0000-000000000000'))->toBeNull() + ->and($repository->getClientEntity('not-a-uuid'))->toBeNull() + ->and($repository->validateClient($client->id, $secret, 'client_credentials'))->toBeFalse() + ->and($repository->validateClient($client->id, $secret, 'authorization_code'))->toBeTrue() + ->and($repository->validateClient($public->id, null, 'refresh_token'))->toBeTrue() + ->and($repository->validateClient($public->id, 'secret', 'refresh_token'))->toBeFalse(); + }); + + it('finalize no scopes for a client that no longer exists', function () { + $client = new ClientEntity('00000000-0000-0000-0000-000000000000', 'Gone', [], true); + + expect((new ScopeRepository())->finalizeScopes([new ScopeEntity('openid')], 'authorization_code', $client))->toBe([]); + }); + + it('never issue a code or token outside a grant', function () { + [$client] = oauth_test_client(); + $entity = new ClientEntity($client->id, 'AppHelm', [OAUTH_TEST_REDIRECT], true); + + $code = new AuthCodeEntity(); + $code->setClient($entity); + $access = new AccessTokenEntity('https://api.fleetbase.test'); + $access->setClient($entity); + $refresh = new RefreshTokenEntity(); + $refresh->setAccessToken($access); + + expect(fn () => (new AuthCodeRepository(new IssueContext()))->persistNewAuthCode($code))->toThrow(LogicException::class) + ->and(fn () => (new AccessTokenRepository(new IssueContext(), 'https://api.fleetbase.test'))->persistNewAccessToken($access))->toThrow(LogicException::class) + ->and(fn () => (new RefreshTokenRepository(new IssueContext()))->persistNewRefreshToken($refresh))->toThrow(LogicException::class); + }); + + it('treat an unknown code or refresh token as revoked', function () { + expect((new AuthCodeRepository(new IssueContext()))->isAuthCodeRevoked('nope'))->toBeTrue() + ->and((new RefreshTokenRepository(new IssueContext()))->isRefreshTokenRevoked('nope'))->toBeTrue(); + }); + + it('open nothing from a payload that is not theirs', function () { + $crypt = new PayloadCrypt(oauth_test_server()->keys()->encryptionKey()); + + expect($crypt->open(null))->toBeNull() + ->and($crypt->open(''))->toBeNull() + ->and($crypt->open('not-encrypted'))->toBeNull(); + }); +}); + +describe('scopes registry', function () { + it('validates names, describes scopes with the organization, and maps review types', function () { + expect(fn () => OAuthScopes::register('Not Valid', []))->toThrow(InvalidArgumentException::class) + ->and(OAuthScopes::get('openid')['organization'])->toBeFalse() + ->and(OAuthScopes::get('missing'))->toBeNull() + ->and(OAuthScopes::scopeForReviewAccessType('storefront-store'))->toBe('storefront:review-access') + ->and(OAuthScopes::scopeForReviewAccessType('unknown'))->toBeNull() + ->and(OAuthScopes::describe(['storefront:read', 'missing'], 'Acme'))->toBe([ + ['scope' => 'storefront:read', 'description' => "See Acme's storefronts", 'organization' => true], + ]) + ->and(OAuthScopes::describe(['storefront:read'])[0]['description'])->toBe("See your organization's storefronts"); + }); +}); + +describe('keys', function () { + it('read key files from storage when no key is configured, and say when there are none', function () { + $keys = new OAuthKeys(); + $remove = function () { + foreach (['private', 'public'] as $type) { + if (file_exists(OAuthKeys::keyPath($type))) { + unlink(OAuthKeys::keyPath($type)); + } + } + }; + config(['oauth-server.private_key' => null, 'oauth-server.public_key' => null]); + if (!is_dir(dirname(OAuthKeys::keyPath('private')))) { + mkdir(dirname(OAuthKeys::keyPath('private')), 0777, true); + } + $remove(); + + expect($keys->configured())->toBeFalse() + ->and(fn () => $keys->privateKeyContents())->toThrow(RuntimeException::class); + + file_put_contents(OAuthKeys::keyPath('private'), oauth_test_keys()['private']); + file_put_contents(OAuthKeys::keyPath('public'), oauth_test_keys()['public']); + + expect($keys->configured())->toBeTrue() + ->and($keys->publicKeyContents())->toBe(oauth_test_keys()['public']); + + $remove(); + }); + + it('need APP_KEY for the payload key', function () { + config(['app.key' => '']); + + expect(fn () => (new OAuthKeys())->encryptionKey())->toThrow(RuntimeException::class); + }); +}); + +describe('clients and grants', function () { + it('accept only https redirect URIs, or loopback http on a development app', function () { + expect(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb'))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('https://app.example.com/cb#x'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('not a url'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://app.example.com/cb', true))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb'))->toBeFalse() + ->and(OAuthClient::isAcceptableRedirectUri('http://127.0.0.1:3000/cb', true))->toBeTrue() + ->and(OAuthClient::isAcceptableRedirectUri('myapp://callback', true))->toBeFalse() + ->and(OAuthClient::isLoopbackUri('https://127.0.0.1/cb'))->toBeFalse(); + }); + + it('match no empty redirect URI, and a public client has no secret to verify', function () { + [$client] = oauth_test_client(['name' => 'Native'], confidential: false); + + expect($client->allowsRedirectUri(null))->toBeFalse() + ->and($client->allowsRedirectUri(''))->toBeFalse() + ->and($client->verifySecret('anything'))->toBeFalse() + ->and($client->toArray()['confidential'])->toBeFalse(); + }); + + it('relate grants to their user, client and organization', function () { + [$client] = oauth_test_client(); + $grant = OAuthGrant::create(['user_uuid' => 'user-owner', 'client_id' => $client->id, 'company_uuid' => 'company-a', 'scopes' => ['openid']]); + + expect($grant->user->uuid)->toBe('user-owner') + ->and($grant->client->id)->toBe($client->id) + ->and($grant->company->uuid)->toBe('company-a') + ->and($client->grants()->count())->toBe(1); + + OAuthGrant::revokeTokensFor([]); + expect($grant->fresh()->isRevoked())->toBeFalse(); + }); +}); + +describe('organizations', function () { + it('can be shared only by an active member who administers them', function () { + OAuthOrganizations::$administratorCheck = null; + oauth_test_roles(['cu-admin-a' => 'Administrator', 'cu-member-a' => 'Dispatcher']); + + $companyA = Company::where('uuid', 'company-a')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $member = User::where('uuid', 'user-member')->first(); + $owner = User::where('uuid', 'user-owner')->first(); + + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeTrue() + ->and(OAuthOrganizations::canShare($member, $companyA))->toBeFalse() + ->and(OAuthOrganizations::canShare($owner, Company::where('uuid', 'company-b')->first()))->toBeFalse(); + + $admin->status = 'inactive'; + expect(OAuthOrganizations::canShare($admin, $companyA))->toBeFalse(); + }); + + it('list the user\'s organizations for the picker and find one by public id', function () { + $member = User::where('uuid', 'user-member')->first(); + + expect(OAuthOrganizations::forPicker($member)->all())->toBe([ + ['id' => 'company_a', 'uuid' => 'company-a', 'name' => 'Acme Deliveries', 'logo_url' => Company::where('uuid', 'company-a')->first()->logo_url, 'can_share' => false], + ['id' => 'company_b', 'uuid' => 'company-b', 'name' => 'Bravo Freight', 'logo_url' => Company::where('uuid', 'company-b')->first()->logo_url, 'can_share' => true], + ]) + ->and(OAuthOrganizations::find($member, 'company_b')?->uuid)->toBe('company-b') + ->and(OAuthOrganizations::find($member, null))->toBeNull() + ->and(OAuthOrganizations::find(User::where('uuid', 'user-admin')->first(), 'company_b'))->toBeNull(); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php new file mode 100644 index 00000000..19e41049 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerEndpointsTest.php @@ -0,0 +1,173 @@ +instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('hash'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('cache'); + + (require __DIR__ . '/../../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + // An organization-level review type, the way fleetops registers Navigator, covered by a scope. + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + OAuthScopes::register('navigator:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['test-navigator']]); +}); + +afterEach(function () { + AppReview::forget('test-navigator'); + oauth_test_teardown(); +}); + +/** + * Sign in user-owner to company A with these scopes; returns the access token. + */ +function oauth_endpoint_token(string $scope, string $userUuid = 'user-owner', string $companyUuid = 'company-a'): string +{ + [$client, $secret] = oauth_test_client(['allowed_scopes' => array_keys(OAuthScopes::all())]); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, ['scope' => $scope], $userUuid, $companyUuid), $verifier); + + return $body['access_token']; +} + +/** + * Run a request through the middleware and controller action, as the router would. + */ +function oauth_endpoint_call(Request $request, array $scopes, Closure $action) +{ + return (new AuthenticateOAuthToken(oauth_test_server()))->handle($request, $action, ...$scopes); +} + +it('publishes discovery metadata that matches the endpoints and the signing key', function () { + $metadata = (new DiscoveryController())->configuration(oauth_test_server())->getData(true); + $jwks = (new DiscoveryController())->jwks(oauth_test_server())->getData(true); + + expect($metadata['issuer'])->toBe('https://api.fleetbase.test') + ->and($metadata['authorization_endpoint'])->toBe('https://api.fleetbase.test/oauth/authorize') + ->and($metadata['token_endpoint'])->toBe('https://api.fleetbase.test/oauth/token') + ->and($metadata['code_challenge_methods_supported'])->toBe(['S256']) + ->and($metadata['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']) + ->and($metadata['scopes_supported'])->toContain('openid', 'organization:read', 'storefront:read') + ->and($jwks['keys'][0]['kid'])->toBe(oauth_test_server()->keys()->keyId()) + ->and($jwks['keys'][0])->toHaveKeys(['n', 'e']) + ->and($jwks['keys'][0])->not->toHaveKey('d'); +}); + +it('returns the organization on the token, not the user\'s current one', function () { + $token = oauth_endpoint_token('openid organization:read'); + Fleetbase\Models\User::where('uuid', 'user-owner')->update(['company_uuid' => 'company-b']); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn ($request) => (new ResourceController())->organization($request)); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['organization'])->toMatchArray([ + 'id' => 'company_a', + 'name' => 'Acme Deliveries', + 'currency' => 'USD', + 'country' => 'US', + ]); +}); + +it('answers userinfo with the claims the scopes allow', function () { + $token = oauth_endpoint_token('openid email'); + + $claims = oauth_endpoint_call(oauth_test_bearer($token, '/oauth/userinfo'), ['openid'], fn ($request) => (new ResourceController())->userinfo($request))->getData(true); + + expect($claims)->toBe(['sub' => 'user_owner', 'email' => 'owner@example.com', 'email_verified' => true]); +}); + +it('refuses a request without the route\'s scope, with an RFC 6750 challenge', function () { + $token = oauth_endpoint_token('openid email'); + + $response = oauth_endpoint_call(oauth_test_bearer($token), ['organization:read'], fn () => throw new RuntimeException('must not run')); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope') + ->and($response->headers->get('WWW-Authenticate'))->toStartWith('Bearer error="insufficient_scope"'); +}); + +describe('review access', function () { + function oauth_review_call(string $token, string $method, string $id = 'company_a', array $input = []) + { + $request = Request::create('/oauth/v1/review-access/test-navigator/' . $id, $method, $input, [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + return oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'test-navigator', $id)); + } + + it('turns access on with a code shown once, reads it without the code, and turns it off', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + $on = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + expect($on['enabled'])->toBeTrue() + ->and($on['phone'])->toBe('+12025550144') + ->and($on['code'])->toMatch('/^\d{6}$/'); + + $again = oauth_review_call($token, 'PUT')->getData(true)['review_access']; + $read = oauth_review_call($token, 'GET')->getData(true)['review_access']; + expect($again)->not->toHaveKey('code') + ->and($read)->not->toHaveKey('code') + ->and($read['has_code'])->toBeTrue(); + + $regenerated = oauth_review_call($token, 'PUT', 'company_a', ['regenerate_code' => true])->getData(true)['review_access']; + expect($regenerated['code'])->toMatch('/^\d{6}$/'); + + $off = oauth_review_call($token, 'DELETE')->getData(true)['review_access']; + expect($off['enabled'])->toBeFalse() + ->and($off['has_code'])->toBeTrue(); + }); + + it('needs the scope that covers the type', function () { + $token = oauth_endpoint_token('openid organization:read'); + + $response = oauth_review_call($token, 'PUT'); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe('insufficient_scope'); + }); + + it('does not know a type nobody registered', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + $request = Request::create('/oauth/v1/review-access/unknown/company_a', 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]); + + $response = oauth_endpoint_call($request, [], fn ($request) => (new ResourceController())->reviewAccess($request, 'unknown', 'company_a')); + + expect($response->getStatusCode())->toBe(404); + }); + + it('cannot reach another organization', function () { + $token = oauth_endpoint_token('openid navigator:review-access'); + + expect(oauth_review_call($token, 'PUT', 'company_b')->getStatusCode())->toBe(404) + ->and(ReviewAccess::count())->toBe(0); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php new file mode 100644 index 00000000..1acf0e41 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerFlowTest.php @@ -0,0 +1,512 @@ + oauth_test_setup()); +afterEach(fn () => oauth_test_teardown()); + +function oauth_flow_authorize_error(array $params): AuthorizeError +{ + try { + oauth_test_server()->validateAuthorizationRequest($params); + } catch (AuthorizeError $e) { + return $e; + } + + throw new RuntimeException('Expected the authorization request to be refused.'); +} + +function oauth_flow_token_error(Request $request, array $scopes = []): TokenError +{ + try { + oauth_test_server()->authenticate($request, $scopes); + } catch (TokenError $e) { + return $e; + } + + throw new RuntimeException('Expected the access token to be refused.'); +} + +describe('the full flow', function () { + it('signs in, issues a signed ID token, and binds the tokens to the chosen organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(200) + ->and($body)->toHaveKeys(['access_token', 'refresh_token', 'id_token', 'expires_in']) + ->and($body['token_type'])->toBe('Bearer') + ->and(explode(' ', $body['scope']))->toEqualCanonicalizing(['openid', 'profile', 'email', 'organization:read']); + + $jwt = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText('empty', 'empty')); + $idToken = $jwt->parser()->parse($body['id_token']); + $claims = $idToken->claims(); + + expect($jwt->validator()->validate($idToken, new SignedWith(new Sha256(), InMemory::plainText(oauth_test_keys()['public']))))->toBeTrue() + ->and($claims->get('iss'))->toBe('https://api.fleetbase.test') + ->and($claims->get('aud'))->toBe([$client->id]) + ->and($claims->get('sub'))->toBe('user_owner') + ->and($claims->get('nonce'))->toBe('nonce-abc') + ->and($claims->get('email'))->toBe('owner@example.com') + ->and($claims->get('email_verified'))->toBeTrue() + ->and($claims->get('name'))->toBe('Olive Owner') + ->and($claims->get('org_id'))->toBe('company_a') + ->and($claims->get('at_hash'))->toBe(IdTokenResponse::accessTokenHash($body['access_token'])) + ->and($claims->has('auth_time'))->toBeTrue() + ->and($idToken->headers()->get('kid'))->toBe(oauth_test_server()->keys()->keyId()); + + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']), ['organization:read']); + + expect($token->company_uuid)->toBe('company-a') + ->and($token->user_uuid)->toBe('user-owner') + ->and($token->grant->scopes)->toContain('organization:read'); + }); + + it('sends iss and state back with the code (RFC 9207)', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $result = $server->approve( + $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())), + User::where('uuid', 'user-owner')->first(), + Company::where('uuid', 'company-a')->first(), + null + ); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + expect($result['redirect'])->toStartWith(OAUTH_TEST_REDIRECT . '?') + ->and($query['state'])->toBe('state-123') + ->and($query['iss'])->toBe('https://api.fleetbase.test') + ->and($query)->toHaveKey('code') + ->and($result['created'])->toBeTrue(); + }); + + it('signs in without an organization when only identity scopes are asked for', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier, ['scope' => 'openid email'], 'user-member', 'company-a'); + + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + $token = oauth_test_server()->authenticate(oauth_test_bearer($body['access_token'])); + + expect($status)->toBe(200) + ->and($token->company_uuid)->toBeNull() + ->and(OAuthGrant::where('user_uuid', 'user-member')->first()->company_uuid)->toBeNull(); + }); + + it('skips the consent screen only for a sign-in the user already approved', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $user = User::where('uuid', 'user-member')->first(); + $signIn = fn (array $overrides = []) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), array_merge(['scope' => 'openid email'], $overrides))); + + expect($server->canSkipConsent($signIn(), $user))->toBeFalse(); + + $server->approve($signIn(), $user, null, null); + + expect($server->canSkipConsent($signIn(), $user))->toBeTrue() + ->and($server->canSkipConsent($signIn(['prompt' => 'consent']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid email profile']), $user))->toBeFalse() + ->and($server->canSkipConsent($signIn(['scope' => 'openid organization:read']), $user))->toBeFalse(); + }); +}); + +describe('PKCE', function () { + it('is required, even for a confidential client', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['code_challenge' => null, 'code_challenge_method' => null])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeTrue() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toContain('state=state-123'); + }); + + it('accepts only S256', function () { + [$client] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $error = oauth_flow_authorize_error(oauth_test_params($client, $verifier, ['code_challenge' => $verifier, 'code_challenge_method' => 'plain'])); + + expect($error->error)->toBe('invalid_request') + ->and($error->description)->toContain('S256'); + }); + + it('refuses a code redeemed with the wrong verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), oauth_test_verifier()); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_grant'); + }); + + it('refuses a code redeemed with no verifier', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['code_verifier' => null]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); + + it('lets a public client sign in with PKCE alone', function () { + [$client] = oauth_test_client(['name' => 'Native app'], confidential: false); + $verifier = oauth_test_verifier(); + [$status, $body] = oauth_test_exchange($client, null, oauth_test_code($client, $verifier), $verifier); + + expect($client->isConfidential())->toBeFalse() + ->and($status)->toBe(200) + ->and($body)->toHaveKey('access_token'); + }); +}); + +describe('redirect URIs', function () { + it('never redirects to an unregistered URI', function () { + [$client] = oauth_test_client(); + + foreach ([ + 'https://evil.example.com/oauth/callback', + OAUTH_TEST_REDIRECT . '?extra=1', + OAUTH_TEST_REDIRECT . '/', + 'http://app.example.com/oauth/callback', + ] as $uri) { + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => $uri])); + + expect($error->error)->toBe('invalid_request') + ->and($error->canRedirect())->toBeFalse() + ->and($error->redirectUrl('https://api.fleetbase.test'))->toBeNull(); + } + }); + + it('requires a redirect URI on every request', function () { + [$client] = oauth_test_client(); + $error = oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['redirect_uri' => null])); + + expect($error->canRedirect())->toBeFalse(); + }); + + it('refuses an unknown or disabled client without redirecting', function () { + [$client] = oauth_test_client(); + $client->forceFill(['disabled_at' => now()])->save(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier()))->error)->toBe('invalid_client') + ->and(oauth_flow_authorize_error(['client_id' => 'not-a-client'])->canRedirect())->toBeFalse(); + }); + + it('lets a development client use any loopback port, and nobody else', function () { + [$dev] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => true]); + [$prod] = oauth_test_client(['redirect_uris' => ['http://127.0.0.1:3000/callback'], 'development' => false]); + + expect($dev->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeTrue() + ->and($dev->allowsRedirectUri('http://127.0.0.1:51234/other'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:51234/callback'))->toBeFalse() + ->and($prod->allowsRedirectUri('http://127.0.0.1:3000/callback'))->toBeTrue(); + }); + + it('refuses a code redeemed with a different redirect URI', function () { + [$client, $secret] = oauth_test_client(['redirect_uris' => [OAUTH_TEST_REDIRECT, 'https://app.example.com/other']]); + $verifier = oauth_test_verifier(); + + [$status, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier, ['redirect_uri' => 'https://app.example.com/other']); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request'); + }); +}); + +describe('authorization codes', function () { + it('expire', function () { + config(['oauth-server.ttl.auth_code' => 'PT1S']); + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + sleep(2); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and($body['hint'] ?? $body['error_description'])->toContain('expired'); + }); + + it('work once, and a replay revokes the tokens already issued from it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [, $first] = oauth_test_exchange($client, $secret, $code, $verifier); + [$status, $body] = oauth_test_exchange($client, $secret, $code, $verifier); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('invalid_request') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$refreshStatus] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $first['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + expect($refreshStatus)->toBe(401); + }); + + it('belong to the client they were issued to', function () { + [$client] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + + [$status] = oauth_test_exchange($other, $otherSecret, oauth_test_code($client, $verifier), $verifier); + + expect($status)->toBe(400); + }); +}); + +describe('scopes', function () { + it('refuses scopes the client was not registered for, or that do not exist', function () { + [$client] = oauth_test_client(['allowed_scopes' => ['openid', 'email']]); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid organization:read']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => 'openid admin:everything']))->error)->toBe('invalid_scope') + ->and(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['scope' => '']))->error)->toBe('invalid_scope'); + }); + + it('refuses an endpoint that needs a scope the token does not have', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $error = oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['storefront:read']); + + expect($error->error)->toBe('insufficient_scope') + ->and($error->status)->toBe(403) + ->and($error->toResponse()->headers->get('WWW-Authenticate'))->toContain('scope="storefront:read"'); + }); + + it('loses a scope at once when an admin removes it from the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $client->forceFill(['allowed_scopes' => ['openid', 'profile', 'email']])->save(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']), ['organization:read'])->error)->toBe('insufficient_scope'); + }); + + it('cannot widen scopes on refresh', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + [$status, $refreshed] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret, 'scope' => 'openid storefront:read']); + + expect($status)->toBe(400) + ->and($refreshed['error'])->toBe('invalid_scope'); + }); + + it('needs an organization the user administers for organization scopes', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn () => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier())); + $member = User::where('uuid', 'user-member')->first(); + $admin = User::where('uuid', 'user-admin')->first(); + $companyA = Company::where('uuid', 'company-a')->first(); + $companyB = Company::where('uuid', 'company-b')->first(); + + expect(fn () => $server->approve($request(), $member, null, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $member, $companyA, null))->toThrow(AuthorizeError::class) + ->and(fn () => $server->approve($request(), $admin, $companyB, null))->toThrow(AuthorizeError::class) + ->and($server->approve($request(), $admin, $companyA, null)['grant']->company_uuid)->toBe('company-a') + ->and($server->approve($request(), $member, $companyB, null)['grant']->company_uuid)->toBe('company-b'); + }); +}); + +describe('refresh tokens', function () { + it('rotate, and a replayed one revokes the whole grant', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $first] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + $refresh = fn (string $token) => oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $token, 'client_id' => $client->id, 'client_secret' => $secret]); + + [$status, $second] = $refresh($first['refresh_token']); + expect($status)->toBe(200) + ->and($second['refresh_token'])->not->toBe($first['refresh_token']) + ->and(oauth_test_server()->authenticate(oauth_test_bearer($second['access_token']))->company_uuid)->toBe('company-a') + ->and(oauth_flow_token_error(oauth_test_bearer($first['access_token']))->error)->toBe('invalid_token'); + + [$replayStatus] = $refresh($first['refresh_token']); + expect($replayStatus)->toBe(401) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($second['access_token']))->error)->toBe('invalid_token'); + + [$afterStatus] = $refresh($second['refresh_token']); + expect($afterStatus)->toBe(401); + }); + + it('stop working when the user no longer administers the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier, [], 'user-admin'), $verifier); + + Fleetbase\Auth\OAuthServer\OAuthOrganizations::$administratorCheck = fn () => false; + [$status, $error] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($error['error'])->toBe('invalid_grant') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); +}); + +describe('access to the organization', function () { + it('ends when the user leaves the organization', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + Fleetbase\Models\CompanyUser::where('uuid', 'cu-owner-a')->delete(); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeTrue(); + }); + + it('ends when the account is deactivated', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + User::where('uuid', 'user-owner')->update(['status' => 'inactive']); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('is refused for a forged or tampered token', function () { + $other = Fleetbase\Auth\OAuthServer\OAuthKeys::generate(2048); + $config = Configuration::forAsymmetricSigner(new Sha256(), InMemory::plainText($other['private']), InMemory::plainText($other['public'])); + $now = new DateTimeImmutable('@' . time()); + $forged = $config->builder()->identifiedBy('forged')->permittedFor('x')->relatedTo('user-owner')->issuedAt($now)->canOnlyBeUsedAfter($now)->expiresAt($now->modify('+1 hour'))->withClaim('scopes', ['organization:read'])->getToken($config->signer(), $config->signingKey()); + + expect(oauth_flow_token_error(oauth_test_bearer($forged->toString()))->error)->toBe('invalid_token') + ->and(oauth_flow_token_error(Request::create('/oauth/v1/organization'))->error)->toBe('invalid_token'); + }); +}); + +describe('revocation', function () { + it('of a refresh token disconnects the app', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'token_type_hint' => 'refresh_token'], [], [], [ + 'HTTP_AUTHORIZATION' => 'Basic ' . base64_encode($client->id . ':' . $secret), + ])); + + expect($response->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeTrue() + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token'); + }); + + it('of an access token revokes only that token', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $response = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => $secret])); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($response->getStatusCode())->toBe(200) + ->and(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and($status)->toBe(200); + }); + + it('needs the client to authenticate, and ignores other clients\' tokens', function () { + [$client, $secret] = oauth_test_client(); + [$other, $otherSecret] = oauth_test_client(['name' => 'Other app']); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + $unauthenticated = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['access_token'], 'client_id' => $client->id, 'client_secret' => 'wrong'])); + $otherClient = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => $body['refresh_token'], 'client_id' => $other->id, 'client_secret' => $otherSecret])); + $unknown = oauth_test_server()->revoke(Request::create('/oauth/revoke', 'POST', ['token' => 'not-a-token', 'client_id' => $client->id, 'client_secret' => $secret])); + + expect($unauthenticated->getStatusCode())->toBe(401) + ->and($otherClient->getStatusCode())->toBe(200) + ->and($unknown->getStatusCode())->toBe(200) + ->and(OAuthGrant::first()->isRevoked())->toBeFalse() + ->and(oauth_test_server()->authenticate(oauth_test_bearer($body['access_token']))->user_uuid)->toBe('user-owner'); + }); + + it('of a grant from Connected apps stops every token under it', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + [, $body] = oauth_test_exchange($client, $secret, oauth_test_code($client, $verifier), $verifier); + + OAuthGrant::first()->revoke(); + + [$status] = oauth_test_token(['grant_type' => 'refresh_token', 'refresh_token' => $body['refresh_token'], 'client_id' => $client->id, 'client_secret' => $secret]); + + expect(oauth_flow_token_error(oauth_test_bearer($body['access_token']))->error)->toBe('invalid_token') + ->and($status)->toBe(401) + ->and(OAuthAccessToken::where('revoked', false)->count())->toBe(0); + }); +}); + +describe('clients', function () { + it('authenticate with their secret, which rotation replaces', function () { + [$client, $secret] = oauth_test_client(); + $verifier = oauth_test_verifier(); + $code = oauth_test_code($client, $verifier); + + [$wrongStatus, $wrong] = oauth_test_exchange($client, 'not-the-secret', $code, $verifier); + expect($wrongStatus)->toBe(401)->and($wrong['error'])->toBe('invalid_client'); + + $newSecret = $client->issueSecret(); + $client->save(); + + [$oldStatus] = oauth_test_exchange($client, $secret, $code, $verifier); + [$newStatus] = oauth_test_exchange($client, $newSecret, $code, $verifier); + + expect($oldStatus)->toBe(401) + ->and($newStatus)->toBe(200) + ->and($client->fresh()->secret)->not->toBe($newSecret) + ->and($client->fresh()->toArray())->not->toHaveKey('secret'); + }); + + it('cannot use grants other than authorization_code and refresh_token', function () { + [$client, $secret] = oauth_test_client(); + + [$status, $body] = oauth_test_token(['grant_type' => 'client_credentials', 'client_id' => $client->id, 'client_secret' => $secret]); + + expect($status)->toBe(400) + ->and($body['error'])->toBe('unsupported_grant_type'); + }); +}); + +describe('prompt and max_age', function () { + it('asks for a fresh sign-in when prompt=login or max_age says so', function () { + [$client] = oauth_test_client(); + $server = oauth_test_server(); + $request = fn (array $overrides) => $server->validateAuthorizationRequest(oauth_test_params($client, oauth_test_verifier(), $overrides)); + + expect($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-10 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request(['prompt' => 'login']), new DateTimeImmutable('-1 minute')))->toBeFalse() + ->and($server->needsReauthentication($request(['max_age' => '60']), new DateTimeImmutable('-2 minutes')))->toBeTrue() + ->and($server->needsReauthentication($request([]), new DateTimeImmutable('-1 day')))->toBeFalse(); + }); + + it('answers prompt=none with interaction_required', function () { + [$client] = oauth_test_client(); + + expect(oauth_flow_authorize_error(oauth_test_params($client, oauth_test_verifier(), ['prompt' => 'none']))->error)->toBe('interaction_required'); + }); +}); diff --git a/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php new file mode 100644 index 00000000..5fa9e3c7 --- /dev/null +++ b/tests/Unit/Auth/OAuthServer/OAuthServerTestSupport.php @@ -0,0 +1,309 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $keys = oauth_test_keys(); + + $container = bind_test_container([ + 'app.key' => 'base64:' . base64_encode(str_repeat('k', 32)), + 'app.url' => 'https://api.fleetbase.test', + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + 'oauth-server.private_key' => $keys['private'], + 'oauth-server.public_key' => $keys['public'], + 'oauth-server.ttl' => ['auth_code' => 'PT10M', 'access_token' => 'PT1H', 'refresh_token' => 'P30D', 'id_token' => 'PT1H'], + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('mysql')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + $table->string('timezone')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('name'); + $table->string('logo_uuid')->nullable(); + $table->string('currency')->nullable(); + $table->string('country')->nullable(); + $table->string('timezone')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('company_users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('files', function ($table) { + $table->string('uuid')->primary(); + $table->string('url')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + (require __DIR__ . '/../../../../migrations/2026_10_25_000001_create_oauth_server_tables.php')->up(); + + $db = $capsule->getConnection('mysql'); + $now = '2026-10-01 00:00:00'; + $db->table('users')->insert([ + ['uuid' => 'user-owner', 'public_id' => 'user_owner', 'type' => 'user', 'status' => 'active', 'email' => 'owner@example.com', 'name' => 'Olive Owner', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-admin', 'public_id' => 'user_admin', 'type' => 'user', 'status' => 'active', 'email' => 'admin@example.com', 'name' => 'Ada Admin', 'email_verified_at' => null, 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'user-member', 'public_id' => 'user_member', 'type' => 'user', 'status' => 'active', 'email' => 'member@example.com', 'name' => 'Mo Member', 'email_verified_at' => $now, 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'owner_uuid' => 'user-owner', 'name' => 'Acme Deliveries', 'currency' => 'USD', 'country' => 'US', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'owner_uuid' => 'user-member', 'name' => 'Bravo Freight', 'currency' => 'SGD', 'country' => 'SG', 'created_at' => $now, 'updated_at' => $now], + ]); + $db->table('company_users')->insert([ + ['uuid' => 'cu-owner-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-owner', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-admin-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-admin', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ['uuid' => 'cu-member-b', 'company_uuid' => 'company-b', 'user_uuid' => 'user-member', 'status' => 'active', 'created_at' => $now, 'updated_at' => $now], + ]); + + // Only user-admin holds company A's Administrator role. + OAuthOrganizations::$administratorCheck = fn ($membership) => $membership->uuid === 'cu-admin-a'; + + OAuthScopes::flush(); + OAuthScopes::registerDefaults(); + OAuthScopes::register('storefront:read', ['description' => "See {organization}'s storefronts", 'organization' => true]); + OAuthScopes::register('storefront:review-access', ['description' => 'Turn App Review sign-in on or off', 'organization' => true, 'review_access_types' => ['storefront-store']]); + + return $capsule; +} + +/** + * Real spatie/permission roles for company memberships, keyed by company_users.uuid. + * + * @param array $roles + */ +function oauth_test_roles(array $roles): void +{ + config([ + 'auth.defaults.guard' => 'sanctum', + 'permission.models.permission' => Fleetbase\Models\Permission::class, + 'permission.models.role' => Fleetbase\Models\Role::class, + 'permission.table_names.roles' => 'roles', + 'permission.table_names.permissions' => 'permissions', + 'permission.table_names.model_has_permissions' => 'model_has_permissions', + 'permission.table_names.model_has_roles' => 'model_has_roles', + 'permission.table_names.role_has_permissions' => 'role_has_permissions', + 'permission.column_names.model_morph_key' => 'model_uuid', + ]); + Container::getInstance()->instance(Spatie\Permission\PermissionRegistrar::class, new class { + public string $pivotRole = 'role_id'; + public string $pivotPermission = 'permission_id'; + public bool $teams = false; + public string $teamsKey = 'team_id'; + + public function getRoleClass(): string + { + return Fleetbase\Models\Role::class; + } + + public function getPermissionClass(): string + { + return Fleetbase\Models\Permission::class; + } + + public function getPermissions(array $params = [], bool $onlyOne = false): Illuminate\Database\Eloquent\Collection + { + return new Illuminate\Database\Eloquent\Collection(); + } + }); + + $schema = Capsule::connection('mysql')->getSchemaBuilder(); + $schema->create('roles', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('model_has_roles', function ($table) { + $table->string('role_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->default('sanctum'); + $table->timestamps(); + }); + $schema->create('model_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('model_type'); + $table->string('model_uuid'); + }); + $schema->create('role_has_permissions', function ($table) { + $table->string('permission_id'); + $table->string('role_id'); + }); + + $db = Capsule::connection('mysql'); + foreach (array_values(array_unique($roles)) as $index => $name) { + $db->table('roles')->insert(['id' => 'role-' . $index, 'name' => $name, 'guard_name' => 'sanctum']); + } + $ids = $db->table('roles')->pluck('id', 'name'); + foreach ($roles as $membership => $name) { + $db->table('model_has_roles')->insert(['role_id' => $ids[$name], 'model_type' => Fleetbase\Models\CompanyUser::class, 'model_uuid' => $membership]); + } +} + +/** + * Register an app. Returns [client, plain secret or null]. + */ +function oauth_test_client(array $attributes = [], bool $confidential = true): array +{ + $client = new OAuthClient(array_merge([ + 'name' => 'AppHelm', + 'redirect_uris' => [OAUTH_TEST_REDIRECT], + 'allowed_scopes' => ['openid', 'profile', 'email', 'organization:read', 'storefront:read'], + 'official' => true, + ], $attributes)); + $secret = $confidential ? $client->issueSecret() : null; + $client->save(); + + return [$client, $secret]; +} + +function oauth_test_server(): OAuthServer +{ + return new OAuthServer(); +} + +function oauth_test_verifier(): string +{ + return OAuthKeys::base64url(random_bytes(48)); +} + +function oauth_test_challenge(string $verifier): string +{ + return OAuthKeys::base64url(hash('sha256', $verifier, true)); +} + +function oauth_test_params(OAuthClient $client, string $verifier, array $overrides = []): array +{ + return array_filter(array_merge([ + 'response_type' => 'code', + 'client_id' => $client->id, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'scope' => 'openid profile email organization:read', + 'state' => 'state-123', + 'nonce' => 'nonce-abc', + 'code_challenge' => oauth_test_challenge($verifier), + 'code_challenge_method' => 'S256', + ], $overrides), fn ($value) => $value !== null); +} + +/** + * Run the consent step and return the code from the redirect. + */ +function oauth_test_code(OAuthClient $client, string $verifier, array $overrides = [], string $userUuid = 'user-owner', ?string $companyUuid = 'company-a'): string +{ + $server = oauth_test_server(); + $authorization = $server->validateAuthorizationRequest(oauth_test_params($client, $verifier, $overrides)); + $user = Fleetbase\Models\User::where('uuid', $userUuid)->first(); + $company = $companyUuid ? Fleetbase\Models\Company::where('uuid', $companyUuid)->first() : null; + $result = $server->approve($authorization, $user, $company, new DateTimeImmutable('-1 minute')); + + parse_str((string) parse_url($result['redirect'], PHP_URL_QUERY), $query); + + return $query['code']; +} + +/** + * POST to the token endpoint. Returns [status, decoded body]. + */ +function oauth_test_token(array $params): array +{ + $request = Request::create('/oauth/token', 'POST', $params); + $response = oauth_test_server()->respondToTokenRequest($request); + + return [$response->getStatusCode(), json_decode($response->getContent(), true)]; +} + +function oauth_test_exchange(OAuthClient $client, ?string $secret, string $code, string $verifier, array $overrides = []): array +{ + return oauth_test_token(array_filter(array_merge([ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'client_secret' => $secret, + 'code' => $code, + 'redirect_uri' => OAUTH_TEST_REDIRECT, + 'code_verifier' => $verifier, + ], $overrides), fn ($value) => $value !== null)); +} + +function oauth_test_bearer(string $accessToken, string $uri = '/oauth/v1/organization'): Request +{ + return Request::create($uri, 'GET', [], [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $accessToken]); +} + +function oauth_test_teardown(): void +{ + OAuthOrganizations::$administratorCheck = null; + OAuthScopes::flush(); + EloquentModel::clearBootedModels(); + Container::setInstance(new FleetbaseTestContainer()); + Facade::clearResolvedInstances(); +} diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index dd7538d3..7c1df368 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -26,7 +26,7 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'twilio.twilio.connections.twilio' => [ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'broadcasting.connections.socketcluster.auth_key' => null, 'broadcasting.connections.socketcluster.options' => [ @@ -71,7 +71,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and(config('twilio.twilio.connections.twilio'))->toBe([ 'sid' => 'existing-sid', 'token' => 'existing-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ]); }); @@ -82,7 +82,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'override-sid', 'token' => 'override-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -107,7 +107,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'rest-sid', 'token' => 'rest-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -135,7 +135,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'error-sid', 'token' => 'error-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -158,7 +158,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ 'sid' => 'warning-sid', 'token' => 'warning-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getStatusCode())->toBe(200) @@ -325,12 +325,12 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect($response->getData(true)['message'])->toBe('recorded') ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse('the credentials under test do not outlive the request'); }); @@ -341,13 +341,13 @@ function setting_controller_twilio_facade_is_cached(): bool $response = (new SettingController())->testSmsProviderConfig(setting_controller_external_probe_request([ 'provider' => 'twilio', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'config' => ['sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ])); expect($response->getData(true))->toMatchArray(['status' => 'error', 'message' => 'recorded']) ->and(SettingControllerRecordingTwilioManager::$sent)->toBe([ - ['to' => '+15555550123', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], + ['to' => '+15555551023', 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999'], ]) ->and(setting_controller_twilio_facade_is_cached())->toBeFalse(); }); @@ -361,11 +361,11 @@ function setting_controller_twilio_facade_is_cached(): bool 'sid' => 'entered-sid', 'token' => 'entered-token', 'from' => '+15555550999', - 'phone' => '+15555550123', + 'phone' => '+15555551023', ])); expect(app('twilio'))->toBe($twilio) - ->and($twilio->messages)->toBe([['+15555550123', 'This is a Twilio test from Fleetbase']]); + ->and($twilio->messages)->toBe([['+15555551023', 'This is a Twilio test from Fleetbase']]); }); test('a twilio manager bound but not yet built is built from the config just applied', function () { diff --git a/tests/Unit/Http/SettingControllerServicesTest.php b/tests/Unit/Http/SettingControllerServicesTest.php index 48205a2a..4391253d 100644 --- a/tests/Unit/Http/SettingControllerServicesTest.php +++ b/tests/Unit/Http/SettingControllerServicesTest.php @@ -103,7 +103,7 @@ function setting_controller_services_fixtures(): void 'services.twilio' => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], 'sentry.dsn' => 'https://sentry.example.test/1', 'sms.default_provider' => SmsService::PROVIDER_TWILIO, @@ -153,7 +153,7 @@ function setting_controller_services_request(array $input = []): AdminRequest 'googleMapsLocale' => 'mn', 'twilioSid' => 'twilio-sid', 'twilioToken' => 'twilio-token', - 'twilioFrom' => '+15555550100', + 'twilioFrom' => '+15555551000', 'sentryDsn' => 'https://sentry.example.test/1', ]) ->and($payload['sms']['defaultProvider'])->toBe(SmsService::PROVIDER_TWILIO) @@ -172,7 +172,7 @@ function setting_controller_services_request(array $input = []): AdminRequest SmsService::PROVIDER_TWILIO => [ 'sid' => 'twilio-sid', 'token' => 'twilio-token', - 'from' => '+15555550100', + 'from' => '+15555551000', ], ]) ->and($payload['sms']['available'][SmsService::PROVIDER_TWILIO])->toBe([ @@ -207,7 +207,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => 'unsupported_gateway', - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'api_key' => 'unsupported-key', @@ -237,7 +237,7 @@ function setting_controller_services_request(array $input = []): AdminRequest $response = (new SettingController())->testSmsProviderConfig(setting_controller_services_request([ 'provider' => SmsService::PROVIDER_TWILIO, - 'phone' => '+15555550123', + 'phone' => '+15555551023', 'message' => 'Hello from Fleetbase', 'config' => [ 'sid' => 'temporary-twilio-sid', diff --git a/tests/Unit/Models/VerificationCodeGuardTest.php b/tests/Unit/Models/VerificationCodeGuardTest.php index aa0dfe5d..60890d9d 100644 --- a/tests/Unit/Models/VerificationCodeGuardTest.php +++ b/tests/Unit/Models/VerificationCodeGuardTest.php @@ -213,6 +213,19 @@ function verification_code_guard_subject(string $phone): User ->and($twilio->messages)->toBe([]); }); +it('never texts a number reserved for fiction, such as an App Review number, and still saves the code', function () { + $twilio = verification_code_guard_database(); + + $code = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + $repeat = VerificationCode::generateSmsVerificationFor(verification_code_guard_subject('+12025550143'), 'storefront_login', ['hashed' => true]); + + // Nothing was reserved, so the guard's cooldown never refuses the repeat either. + expect($code->exists)->toBeTrue() + ->and($repeat->exists)->toBeTrue() + ->and($code->meta)->not->toHaveKey('trace') + ->and($twilio->messages)->toBe([]); +}); + it('leaves the code row untouched when the guard is disabled', function () { $twilio = verification_code_guard_database(['enabled' => false]); diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 68d485a3..30726a96 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -939,7 +939,7 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF ['loadMigrationsFrom', dirname(__DIR__, 3) . '/src/Providers/../../migrations'], ['loadViewsFrom', dirname(__DIR__, 3) . '/src/Providers/../../views', 'fleetbase'] ) - ->and($provider->schedule->commands)->toHaveCount(9) + ->and($provider->schedule->commands)->toHaveCount(10) ->and(array_map(fn ($event) => $event->name, $provider->schedule->commands))->toBe([ 'cache:prune-stale-tags', 'model:prune', @@ -948,13 +948,15 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF 'purge:activity-logs --force --no-interaction --days 2 --keep-backups=30', 'purge:scheduled-task-logs --force --no-interaction --days 1 --keep-backups=30', 'model:prune', + 'oauth-server:prune', 'telemetry:ping', 'sandbox:sync', ]) ->and($provider->schedule->commands[1]->parameters)->toBe(['--model' => Spatie\ScheduleMonitor\Models\MonitoredScheduledTaskLogItem::class]) ->and($provider->schedule->commands[6]->parameters)->toBe(['--model' => Fleetbase\Models\OAuthState::class]) ->and($provider->schedule->commands[6]->methods)->toBe([['hourly']]) - ->and($provider->schedule->commands[8]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) + ->and($provider->schedule->commands[7]->methods)->toBe([['daily']]) + ->and($provider->schedule->commands[9]->methods)->toBe([['hourly'], ['name', 'sandbox-sync'], ['withoutOverlapping']]) ->and($provider->schedule->jobs)->toHaveCount(1) ->and($provider->schedule->jobs[0]->name)->toBe(Fleetbase\Jobs\MaterializeSchedulesJob::class) ->and($provider->schedule->jobs[0]->methods)->toBe([['dailyAt', '01:00'], ['name', 'materialize-schedules'], ['withoutOverlapping']]) diff --git a/tests/Unit/SmsServiceGuardTest.php b/tests/Unit/SmsServiceGuardTest.php index 5ad59e67..6b1d77fc 100644 --- a/tests/Unit/SmsServiceGuardTest.php +++ b/tests/Unit/SmsServiceGuardTest.php @@ -142,3 +142,13 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): expect($failed)->not->toBeNull() ->and($failed[2]['error'])->toBe('invalid sender'); }); + +test('sms service never texts a number reserved for fiction, such as an App Review number', function () { + sms_service_guard_setup(); + $service = new SmsServiceGuardProbe(); + + $result = $service->send('+1 202 555 0143', 'hello', ['purpose' => 'storefront_login']); + + expect($result)->toMatchArray(['success' => true, 'skipped' => true]) + ->and($service->dispatches)->toBe([]); +}); diff --git a/tests/Unit/Support/AppReviewTest.php b/tests/Unit/Support/AppReviewTest.php new file mode 100644 index 00000000..3f9a20fd --- /dev/null +++ b/tests/Unit/Support/AppReviewTest.php @@ -0,0 +1,441 @@ +pluck('name')->intersect($this->granted)->isEmpty(); + } +} + +class AppReviewTestNetwork extends FleetbaseModel +{ + protected $table = 'app_review_test_networks'; + protected $guarded = []; +} + +/** + * An in-memory database with two stores in two companies, a network and the review access + * table, and the store and network types registered the way Storefront registers them. + */ +function app_review_database(): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = bind_test_container([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'testing', + ]); + $container->instance('hash', new class { + public function make($value, array $options = []): string + { + return password_hash((string) $value, PASSWORD_BCRYPT); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return is_string($hashedValue) && password_verify((string) $value, $hashedValue); + } + }); + $container->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore())); + $container->instance('responsecache', new class { + public function clear(): void + { + } + }); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'testing'); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('testing'); + $container->instance('db', $capsule->getDatabaseManager()); + $container->instance('db.schema', $capsule->getConnection('testing')->getSchemaBuilder()); + Facade::clearResolvedInstance('db'); + Facade::clearResolvedInstance('db.schema'); + + $schema = $capsule->getConnection('testing')->getSchemaBuilder(); + foreach (['app_review_test_stores', 'app_review_test_networks'] as $table) { + $schema->create($table, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + // Company lives on the core connection. + $capsule->getConnection('mysql')->getSchemaBuilder()->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + // The migration, as it runs on the core connection. + (require __DIR__ . '/../../../migrations/2026_10_24_000000_create_review_accesses_table.php')->up(); + + $db = $capsule->getConnection('testing'); + $db->table('app_review_test_stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_a', 'company_uuid' => 'company-a'], + ['uuid' => 'store-b', 'public_id' => 'store_b', 'company_uuid' => 'company-b'], + ]); + $db->table('app_review_test_networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_a', 'company_uuid' => 'company-a'], + ]); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_a', 'name' => 'Corner Co'], + ['uuid' => 'company-b', 'public_id' => 'company_b', 'name' => 'Night Co'], + ]); + + ReviewAccess::creating(fn ($access) => $access->uuid ??= (string) Str::uuid()); + + AppReview::register('test-store', ['model' => AppReviewTestStore::class, 'phone' => '+1 (202) 555-0143', 'name' => 'Storefront']); + AppReview::register('test-network', ['model' => AppReviewTestNetwork::class, 'phone' => '+12025550143', 'name' => 'Storefront']); + AppReview::register('test-navigator', ['model' => Company::class, 'phone' => '+12025550144', 'name' => 'Navigator']); + + return $capsule; +} + +function app_review_store(string $uuid): AppReviewTestStore +{ + return AppReviewTestStore::where('uuid', $uuid)->first(); +} + +function app_review_console_request(string $company, array $input = [], string $method = 'PUT'): Request +{ + $request = Request::create('/int/v1/review-access', $method, $input); + $request->setLaravelSession(new SessionStore('app-review-test', new ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; +} + +afterEach(function () { + foreach (['test-store', 'test-network', 'test-navigator', 'test-guarded', 'test-resolved', 'test-unreachable'] as $type) { + AppReview::forget($type); + } + EloquentModel::unsetConnectionResolver(); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('review numbers must come from the range reserved for fiction', function () { + bind_test_container(); + + expect(AppReview::isFictionalPhone('+12025550143'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+1 (415) 555-0199'))->toBeTrue() + ->and(AppReview::isFictionalPhone('+12025550200'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+12025551234'))->toBeFalse() + ->and(AppReview::isFictionalPhone('+97699112233'))->toBeFalse() + ->and(AppReview::isFictionalPhone(null))->toBeFalse() + ->and(AppReview::formatPhone('+12025550143'))->toBe('+1 202 555 0143') + ->and(fn () => AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025551234']))->toThrow(InvalidArgumentException::class) + ->and(fn () => AppReview::register('test-guarded', ['model' => 'Missing\\Model', 'phone' => '+12025550143']))->toThrow(InvalidArgumentException::class); + + // libphonenumber accepts the numbers the apps register. + $util = libphonenumber\PhoneNumberUtil::getInstance(); + foreach (['+12025550143', '+12025550144'] as $phone) { + expect($util->isValidNumber($util->parse($phone, null)))->toBeTrue(); + } +}); + +test('turning access on creates a code that only works for that subject', function () { + app_review_database(); + $storeA = app_review_store('store-a'); + $storeB = app_review_store('store-b'); + + expect(AppReview::typeOf($storeA))->toBe('test-store') + ->and(AppReview::isEnabledFor($storeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+12025550143', '000000'))->toBeFalse(); + + [$access, $codeA] = AppReview::update($storeA, true); + do { + [, $codeB] = AppReview::update($storeB, true, true); + } while ($codeB === $codeA); + + expect($codeA)->toMatch('/^\d{6}$/') + ->and($access->getRawOriginal('code'))->not->toBe($codeA) + ->and($access->toArray())->not->toHaveKey('code') + ->and(AppReview::isEnabledFor($storeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+1 202 555 0143', $codeA))->toBeTrue() + ->and(AppReview::acceptsCode($storeA, '+12025550143', $codeB))->toBeFalse() + ->and(AppReview::acceptsCode($storeB, '+12025550143', $codeA))->toBeFalse() + // Only the type's own number signs in. + ->and(AppReview::acceptsCode($storeA, '+12025550144', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode($storeA, '+97699112233', $codeA))->toBeFalse() + ->and(AppReview::acceptsCode(null, '+12025550143', $codeA))->toBeFalse(); +}); + +test('turning access off keeps the code, and regenerating replaces it', function () { + app_review_database(); + $store = app_review_store('store-a'); + + [, $code] = AppReview::update($store, true); + [, $none] = AppReview::update($store, false); + expect($none)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeFalse() + ->and(AppReview::status($store))->toMatchArray(['enabled' => false, 'has_code' => true]); + + [, $restored] = AppReview::update($store, true); + expect($restored)->toBeNull() + ->and(AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue(); + + [, $new] = AppReview::update($store, null, true); + expect($new)->toMatch('/^\d{6}$/') + ->and(AppReview::acceptsCode($store, '+12025550143', $new))->toBeTrue() + ->and($new === $code || !AppReview::acceptsCode($store, '+12025550143', $code))->toBeTrue() + ->and(ReviewAccess::count())->toBe(1); +}); + +test('a company can be the subject, with its own review number', function () { + app_review_database(); + $company = Company::where('uuid', 'company-a')->first(); + + [, $code] = AppReview::update($company, true); + + expect(AppReview::typeOf($company))->toBe('test-navigator') + ->and(AppReview::status($company))->toMatchArray(['type' => 'test-navigator', 'phone' => '+12025550144', 'phone_display' => '+1 202 555 0144', 'enabled' => true]) + ->and(AppReview::acceptsCode($company, '+12025550144', $code))->toBeTrue() + ->and(AppReview::acceptsCode($company, '+12025550143', $code))->toBeFalse() + ->and(ReviewAccess::first()->company_uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_a', 'company-a')?->uuid)->toBe('company-a') + ->and(AppReview::resolveSubject('test-navigator', 'company_b', 'company-a'))->toBeNull(); +}); + +test('a subject is found by uuid or public id in the signed-in company only', function () { + app_review_database(); + + expect(AppReview::resolveSubject('test-store', 'store-a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_a', 'company-a')?->uuid)->toBe('store-a') + ->and(AppReview::resolveSubject('test-store', 'store_b', 'company-a'))->toBeNull() + ->and(AppReview::resolveSubject('test-store', 'store_a', null))->toBeNull() + ->and(AppReview::resolveSubject('unknown-type', 'store_a', 'company-a'))->toBeNull(); +}); + +test('the instance-wide review accounts only accept their code for listed identities', function () { + bind_test_container([ + 'app.review.code' => '135790', + 'app.review.accounts' => [' Reviewer@Example.Test ', '+12025550100'], + ]); + + expect(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'reviewer@example.test'))->toBeTrue() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', 'other@example.test'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135790'))->toBeTrue() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', '135791'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', 'other@example.test', '135790'))->toBeFalse() + ->and(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', null))->toBeFalse(); + + config(['app.review.code' => null]); + expect(AppReview::configuredAccountMatches('app.review.code', 'app.review.accounts', '+12025550100', ''))->toBeFalse() + ->and(AppReview::isConfiguredAccount('app.review.code', 'app.review.accounts', '+12025550100'))->toBeFalse(); +}); + +test('the Console endpoint shows the code once and never again', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + $controller = new ReviewAccessController(); + + $initial = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + expect($initial)->toBe([ + 'type' => 'test-store', + 'enabled' => false, + 'phone' => '+12025550143', + 'phone_display' => '+1 202 555 0143', + 'has_code' => false, + 'code_generated_at' => null, + 'enabled_at' => null, + ]); + + $on = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + $again = $controller->show('test-store', 'store_a', app_review_console_request('company-a', [], 'GET'))->getData(true)['review_access']; + $saved = $controller->update('test-store', 'store_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']; + + expect($on['enabled'])->toBeTrue() + ->and($on['code'])->toMatch('/^\d{6}$/') + ->and($again)->not->toHaveKey('code') + ->and($saved)->not->toHaveKey('code') + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', $on['code']))->toBeTrue(); + + // Another company's store, and a type nobody registered, are not found. + expect($controller->update('test-store', 'store_b', app_review_console_request('company-a', ['enabled' => true]))->getStatusCode())->toBe(404) + ->and($controller->show('nothing', 'store_a', app_review_console_request('company-a', [], 'GET'))->getStatusCode())->toBe(404) + ->and($controller->update('test-network', 'network_a', app_review_console_request('company-a', ['enabled' => true]))->getData(true)['review_access']['type'])->toBe('test-network'); +}); + +test('a registered permission is required to change access', function () { + app_review_database(); + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront manage-app-review store']); + + // Nobody signed in, so nobody holds the permission: refused before anything changes. + $response = (new ReviewAccessController())->update('test-guarded', 'store_a', app_review_console_request('company-a', ['enabled' => true])); + + expect($response->getStatusCode())->toBe(401) + ->and(ReviewAccess::count())->toBe(0); +})->skip(fn () => !method_exists(Fleetbase\Support\Auth::class, 'getUserFromSession')); + +test('the registry answers for unknown types, any type\'s number and numbers it cannot format', function () { + app_review_database(); + + expect(array_keys(AppReview::types()))->toContain('test-store', 'test-network', 'test-navigator') + ->and(AppReview::typeOf(new AppReviewTestUnregistered()))->toBeNull() + ->and(AppReview::typeOf(null))->toBeNull() + ->and(AppReview::definition('nothing'))->toBeNull() + ->and(AppReview::phoneFor('nothing'))->toBeNull() + // Without a type, any registered number is a review number. + ->and(AppReview::isReviewPhone('+1 202 555 0144'))->toBeTrue() + ->and(AppReview::isReviewPhone('+12025550199'))->toBeFalse() + ->and(AppReview::isReviewPhone(''))->toBeFalse() + ->and(AppReview::isReviewPhone(null, 'test-store'))->toBeFalse() + // Only North American numbers are spaced out. + ->and(AppReview::formatPhone('+97699112233'))->toBe('+97699112233') + ->and(AppReview::formatPhone(null))->toBeNull(); +}); + +test('a type can bring its own resolver for the subject a Console user may manage', function () { + app_review_database(); + $calls = []; + AppReview::register('test-resolved', [ + 'model' => AppReviewTestUnregistered::class, + 'phone' => '+12025550150', + 'resolve' => function (string $id, ?string $companyUuid) use (&$calls) { + $calls[] = [$id, $companyUuid]; + + return AppReviewTestUnregistered::where('public_id', $id)->first(); + }, + ]); + + expect(AppReview::resolveSubject('test-resolved', 'store_b', 'company-a')?->uuid)->toBe('store-b') + ->and($calls)->toBe([['store_b', 'company-a']]); +}); + +test('access is absent, not an error, for an unsaved subject, an unregistered one, or before the migration', function () { + $capsule = app_review_database(); + + expect(AppReview::accessFor(new AppReviewTestStore()))->toBeNull() + ->and(AppReview::accessFor(new AppReviewTestUnregistered(['uuid' => 'store-a'])))->toBeNull() + ->and(fn () => AppReview::update(new AppReviewTestUnregistered(['uuid' => 'store-a']), true))->toThrow(InvalidArgumentException::class); + + $capsule->getConnection('testing')->getSchemaBuilder()->drop('review_accesses'); + + expect(AppReview::accessFor(app_review_store('store-a')))->toBeNull() + ->and(AppReview::isEnabledFor(app_review_store('store-a')))->toBeFalse() + ->and(AppReview::acceptsCode(app_review_store('store-a'), '+12025550143', '123456'))->toBeFalse(); +}); + +test('a subject whose table cannot be read is matched by uuid only', function () { + app_review_database(); + $hasColumn = new ReflectionMethod(AppReview::class, 'hasColumn'); + + expect($hasColumn->invoke(null, AppReviewTestUnreachable::class, 'public_id'))->toBeFalse() + ->and($hasColumn->invoke(null, AppReviewTestStore::class, 'public_id'))->toBeTrue(); +}); + +test('the Console endpoint lets administrators and permission holders in, and nobody else', function () { + app_review_database(); + app()->instance('validator', new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app())); + Request::macro('validate', function (array $rules) { + return app('validator')->make($this->all(), $rules)->validate(); + }); + config(['permission.table_names.permissions' => 'permissions', 'auth.defaults.guard' => 'sanctum', 'auth.guards.sanctum' => ['driver' => 'sanctum']]); + Capsule::connection('mysql')->getSchemaBuilder()->create('permissions', function ($table) { + $table->string('id')->primary(); + $table->string('name'); + $table->string('guard_name')->nullable(); + $table->timestamps(); + }); + Capsule::connection('mysql')->table('permissions')->insert([ + ['id' => 'permission-manage', 'name' => 'storefront manage-app-review store', 'guard_name' => 'sanctum'], + ['id' => 'permission-wildcard', 'name' => 'storefront * store', 'guard_name' => 'sanctum'], + ['id' => 'permission-view', 'name' => 'storefront view store', 'guard_name' => 'sanctum'], + ]); + AppReview::forget('test-store'); + AppReview::register('test-guarded', [ + 'model' => AppReviewTestStore::class, + 'phone' => '+12025550143', + 'permission' => 'storefront manage-app-review store', + 'view_permission' => 'storefront view store', + ]); + + $as = function (?AppReviewTestUser $user, array $input = [], string $method = 'PUT') { + $request = app_review_console_request('company-a', $input, $method); + $request->setUserResolver(fn () => $user); + + return $request; + }; + $user = function (string $type, array $granted = []) { + $user = new AppReviewTestUser(); + $user->type = $type; + $user->granted = $granted; + + return $user; + }; + $controller = new ReviewAccessController(); + + $nobody = $controller->show('test-guarded', 'store_a', $as(null, [], 'GET')); + $viewer = $controller->show('test-guarded', 'store_a', $as($user('user', ['storefront view store']), [], 'GET')); + $readOnly = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront view store']), ['enabled' => true])); + $manager = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront manage-app-review store']), ['enabled' => true])); + $wildcard = $controller->update('test-guarded', 'store_a', $as($user('user', ['storefront * store']), ['enabled' => false])); + $admin = $controller->update('test-guarded', 'store_a', $as($user('admin'), ['regenerate_code' => true])); + + expect($nobody->getStatusCode())->toBe(401) + ->and($viewer->getData(true)['review_access']['type'])->toBe('test-guarded') + ->and($readOnly->getStatusCode())->toBe(401) + ->and($manager->getData(true)['review_access'])->toHaveKey('code') + ->and($wildcard->getData(true)['review_access']['enabled'])->toBeFalse() + ->and($admin->getData(true)['review_access'])->toHaveKey('code'); + + // A permission the install never seeded lets nobody but administrators in. + AppReview::register('test-guarded', ['model' => AppReviewTestStore::class, 'phone' => '+12025550143', 'permission' => 'storefront unseeded store']); + expect($controller->update('test-guarded', 'store_a', $as($user('user', ['storefront unseeded store']), ['enabled' => true]))->getStatusCode())->toBe(401); +}); diff --git a/tests/Unit/Support/TwoFactorAuthTest.php b/tests/Unit/Support/TwoFactorAuthTest.php index cb64d7a7..77a5163e 100644 --- a/tests/Unit/Support/TwoFactorAuthTest.php +++ b/tests/Unit/Support/TwoFactorAuthTest.php @@ -217,7 +217,7 @@ function two_factor_auth_fixtures(): array 'uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => $company->uuid, 'email' => 'user@example.com', - 'phone' => '+15555550100', + 'phone' => '+15555551000', 'username' => 'test-user', 'name' => 'Test User', ]);