From 53f11d70b02eb2a2756e8912d8d992f01b5df476 Mon Sep 17 00:00:00 2001 From: Alex Vanderbist Date: Fri, 22 May 2026 16:17:02 +0200 Subject: [PATCH 1/3] Add OAuth2 (PKCE + device code) login alongside personal access tokens `flare login` now defaults to a browser-based PKCE flow against Passport, storing refreshable OAuth records per host. `--device` runs the device-code flow for headless terminals (auto-fallback when stdin is non-interactive). `--token` preserves the legacy paste-a-token UX. Stored OAuth tokens refresh transparently before each API call, with a `retryOn`-driven safety net for 401s using the openapi-cli 1.3.0 hook. `flare logout --all` wipes every host. --- README.md | 17 +- app/Commands/LoginCommand.php | 155 ++++++++++++++- app/Commands/LogoutCommand.php | 18 +- app/Providers/AppServiceProvider.php | 38 +++- app/Services/CredentialStore.php | 176 ++++++++++++++--- app/Services/OAuth/DeviceAuthorization.php | 32 +++ app/Services/OAuth/DeviceLoginFlow.php | 65 ++++++ app/Services/OAuth/DevicePollResult.php | 37 ++++ app/Services/OAuth/LocalCallbackServer.php | 198 +++++++++++++++++++ app/Services/OAuth/OAuthEndpoints.php | 37 ++++ app/Services/OAuth/OAuthException.php | 16 ++ app/Services/OAuth/OAuthHttpClient.php | 161 +++++++++++++++ app/Services/OAuth/PkceCodes.php | 26 +++ app/Services/OAuth/PkceLoginFlow.php | 93 +++++++++ app/Services/OAuth/TokenRecord.php | 91 +++++++++ app/Services/OAuth/TokenRefresher.php | 25 +++ composer.json | 2 +- composer.lock | 14 +- config/flare.php | 27 +++ tests/Feature/CredentialStoreTest.php | 193 +++++++++++++++++- tests/Feature/DeviceLoginFlowTest.php | 115 +++++++++++ tests/Feature/LocalCallbackServerTest.php | 61 ++++++ tests/Feature/LoginCommandTest.php | 173 ++++++++++++++-- tests/Feature/LogoutCommandTest.php | 28 +++ tests/Feature/OAuthHttpClientTest.php | 217 +++++++++++++++++++++ tests/Feature/OpenApiRegistrationTest.php | 47 +++++ tests/Feature/PkceLoginFlowTest.php | 109 +++++++++++ tests/Unit/OAuthEndpointsTest.php | 40 ++++ tests/Unit/PkceCodesTest.php | 44 +++++ tests/Unit/TokenRecordTest.php | 82 ++++++++ tests/Unit/TokenRefresherTest.php | 59 ++++++ 31 files changed, 2333 insertions(+), 63 deletions(-) create mode 100644 app/Services/OAuth/DeviceAuthorization.php create mode 100644 app/Services/OAuth/DeviceLoginFlow.php create mode 100644 app/Services/OAuth/DevicePollResult.php create mode 100644 app/Services/OAuth/LocalCallbackServer.php create mode 100644 app/Services/OAuth/OAuthEndpoints.php create mode 100644 app/Services/OAuth/OAuthException.php create mode 100644 app/Services/OAuth/OAuthHttpClient.php create mode 100644 app/Services/OAuth/PkceCodes.php create mode 100644 app/Services/OAuth/PkceLoginFlow.php create mode 100644 app/Services/OAuth/TokenRecord.php create mode 100644 app/Services/OAuth/TokenRefresher.php create mode 100644 config/flare.php create mode 100644 tests/Feature/DeviceLoginFlowTest.php create mode 100644 tests/Feature/LocalCallbackServerTest.php create mode 100644 tests/Feature/OAuthHttpClientTest.php create mode 100644 tests/Feature/PkceLoginFlowTest.php create mode 100644 tests/Unit/OAuthEndpointsTest.php create mode 100644 tests/Unit/PkceCodesTest.php create mode 100644 tests/Unit/TokenRecordTest.php create mode 100644 tests/Unit/TokenRefresherTest.php diff --git a/README.md b/README.md index dfb0788..bbf6367 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,25 @@ composer global require spatie/flare-cli ### Authentication ```bash -# Log in with your Flare API token +# Browser-based OAuth login (default) flare login -# Log out +# Device-code flow โ€” useful over SSH, in containers, or any headless terminal +flare login --device + +# Paste an existing personal access token +flare login --token + +# Log out of the active host flare logout + +# Log out of every host +flare logout --all ``` -Get your API token at [flareapp.io/settings/api-tokens](https://flareapp.io/settings/api-tokens). +`flare login` opens your browser to flareapp.io to confirm the requested scopes. The CLI listens on a random loopback port for the callback, exchanges the authorization code for tokens, and refreshes them transparently on subsequent calls. Personal access tokens are still supported via `--token` for scripts and CI; generate one at [flareapp.io/settings/api-tokens](https://flareapp.io/settings/api-tokens). + +Set `FLARE_BASE_URL` to point the CLI at a non-production environment (for example `FLARE_BASE_URL=https://passport-oauth.test/api`). Set `FLARE_OAUTH_CLIENT_ID` to override the baked-in Flare CLI OAuth client UUID. ### Commands diff --git a/app/Commands/LoginCommand.php b/app/Commands/LoginCommand.php index cc1f049..7442ff3 100644 --- a/app/Commands/LoginCommand.php +++ b/app/Commands/LoginCommand.php @@ -5,6 +5,11 @@ use App\Concerns\RendersBanner; use App\Services\CredentialStore; use App\Services\FlareUrlResolver; +use App\Services\OAuth\DeviceAuthorization; +use App\Services\OAuth\DeviceLoginFlow; +use App\Services\OAuth\OAuthException; +use App\Services\OAuth\PkceLoginFlow; +use App\Services\OAuth\TokenRecord; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use LaravelZero\Framework\Commands\Command; @@ -13,19 +18,65 @@ class LoginCommand extends Command { use RendersBanner; - protected $signature = 'login'; + protected $signature = 'login + {--token : Paste a personal access token instead of using the browser flow} + {--device : Use the device code flow (for headless terminals)} + {--timeout=120 : Seconds to wait for the OAuth callback}'; - protected $description = 'Store your Flare API token for authentication'; + protected $description = 'Authenticate with Flare via OAuth, device code, or a personal access token'; - public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolver): int - { + public function handle( + CredentialStore $credentials, + FlareUrlResolver $urlResolver, + PkceLoginFlow $pkce, + DeviceLoginFlow $device, + ): int { $this->renderBanner($this->output); + $this->showActiveContext($urlResolver); + + if ($this->option('token')) { + return $this->loginWithPersonalAccessToken($credentials, $urlResolver); + } + + if ($this->option('device')) { + return $this->loginWithDeviceCode($credentials, $urlResolver, $device); + } + + if (! $this->isInteractiveTerminal()) { + $this->warn('Non-interactive terminal detected. Falling back to --device.'); + $this->newLine(); + + return $this->loginWithDeviceCode($credentials, $urlResolver, $device); + } + return $this->loginWithBrowser($credentials, $urlResolver, $pkce); + } + + private function isInteractiveTerminal(): bool + { + if ($this->input->isInteractive()) { + return true; + } + + return defined('STDIN') && function_exists('stream_isatty') && @stream_isatty(STDIN); + } + + private function showActiveContext(FlareUrlResolver $urlResolver): void + { $this->line("Active API base URL: getApiBaseUrl()}>{$urlResolver->getApiBaseUrl()}"); $this->line("Active auth host: {$urlResolver->getHostKey()}"); $this->newLine(); - $tokenUrl = "{$urlResolver->getAppUrl()}/account/api-tokens"; + } + + private function loginWithPersonalAccessToken(CredentialStore $credentials, FlareUrlResolver $urlResolver): int + { + if ($credentials->getRecord() !== null) { + $this->warn('A browser-based OAuth session already exists for this host.'); + $this->line('Continuing will replace it with the personal access token below.'); + $this->newLine(); + } + $tokenUrl = "{$urlResolver->getAppUrl()}/account/api-tokens"; $this->line("You can generate a token at {$tokenUrl}"); $this->newLine(); @@ -39,7 +90,7 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv try { $response = Http::withToken($token)->get("{$urlResolver->getApiBaseUrl()}/me"); - } catch (ConnectionException $e) { + } catch (ConnectionException) { $this->error('Could not connect to Flare. Please check your internet connection.'); return self::FAILURE; @@ -53,8 +104,55 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv $credentials->setToken($token); - $email = $response->json('email', 'unknown'); + return $this->reportSuccess($response->json('email', 'unknown'), $urlResolver); + } + + private function loginWithBrowser( + CredentialStore $credentials, + FlareUrlResolver $urlResolver, + PkceLoginFlow $pkce, + ): int { + $this->line('Opening your browser to log in. Sign in and approve the requested permissions.'); + $this->line('Run `flare login --token` if you prefer pasting a personal access token.'); + $this->newLine(); + + $opener = $this->makeBrowserOpener(); + $logger = fn (string $message) => $this->line($message); + + try { + $record = $pkce->run($opener, $logger, timeoutSeconds: (int) $this->option('timeout')); + } catch (OAuthException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $credentials->setRecord($record); + + $email = $this->fetchEmail($record, $urlResolver); + + return $this->reportSuccess($email ?? 'unknown', $urlResolver); + } + + private function fetchEmail(TokenRecord $record, FlareUrlResolver $urlResolver): ?string + { + try { + $response = Http::withToken($record->accessToken)->get("{$urlResolver->getApiBaseUrl()}/me"); + } catch (ConnectionException) { + return null; + } + + if (! $response->successful()) { + return null; + } + + $email = $response->json('email'); + return is_string($email) ? $email : null; + } + + private function reportSuccess(string $email, FlareUrlResolver $urlResolver): int + { $this->newLine(); $this->info(" ๐ŸŽ‰ Successfully logged in as {$email} "); $this->line("Stored credentials for {$urlResolver->getHostKey()}."); @@ -66,4 +164,47 @@ public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolv return self::SUCCESS; } + + /** + * @return callable(string): void + */ + private function makeBrowserOpener(): callable + { + return PkceLoginFlow::defaultBrowserOpener(...); + } + + private function loginWithDeviceCode( + CredentialStore $credentials, + FlareUrlResolver $urlResolver, + DeviceLoginFlow $device, + ): int { + $this->line('Starting device code authentication.'); + $this->newLine(); + + $announce = function (DeviceAuthorization $auth): void { + $verificationUrl = $auth->verificationUriComplete ?? $auth->verificationUri; + + $this->line(' Open this URL in any browser and confirm the code below:'); + $this->newLine(); + $this->line(" {$verificationUrl}"); + $this->newLine(); + $this->line(" User code: {$auth->userCode}"); + $this->newLine(); + $this->line('Waiting for confirmation...'); + }; + + try { + $record = $device->run($announce); + } catch (OAuthException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $credentials->setRecord($record); + + $email = $this->fetchEmail($record, $urlResolver); + + return $this->reportSuccess($email ?? 'unknown', $urlResolver); + } } diff --git a/app/Commands/LogoutCommand.php b/app/Commands/LogoutCommand.php index 29677c5..73a357b 100644 --- a/app/Commands/LogoutCommand.php +++ b/app/Commands/LogoutCommand.php @@ -8,12 +8,28 @@ class LogoutCommand extends Command { - protected $signature = 'logout'; + protected $signature = 'logout {--all : Remove credentials for every configured host}'; protected $description = 'Clear your stored Flare credentials'; public function handle(CredentialStore $credentials, FlareUrlResolver $urlResolver): int { + if ($this->option('all')) { + $hosts = $credentials->getConfiguredHosts(); + + if ($hosts === []) { + $this->info('No stored credentials to remove.'); + + return self::SUCCESS; + } + + $credentials->flushAll(); + + $this->info('Removed credentials for: '.implode(', ', $hosts).'.'); + + return self::SUCCESS; + } + $credentials->flush(); $this->info("Logged out of {$urlResolver->getHostKey()} successfully."); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2babf78..be2275e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,11 @@ use App\Services\CredentialStore; use App\Services\FlareDescriber; use App\Services\FlareUrlResolver; +use App\Services\OAuth\DeviceLoginFlow; +use App\Services\OAuth\OAuthEndpoints; +use App\Services\OAuth\OAuthHttpClient; +use App\Services\OAuth\PkceLoginFlow; +use App\Services\OAuth\TokenRefresher; use Illuminate\Console\Command; use Illuminate\Http\Client\Response; use Illuminate\Support\ServiceProvider; @@ -25,7 +30,14 @@ public function boot(): void ->useOperationIds() ->baseUrl($urlResolver->getApiBaseUrl()) ->cache(ttl: 60 * 60 * 24) - ->auth(fn () => app(CredentialStore::class)->getToken()) + ->auth(fn () => app(CredentialStore::class)->getAccessToken()) + ->retryOn(function (Response $response) { + if ($response->status() !== 401) { + return false; + } + + return app(CredentialStore::class)->forceRefresh(); + }) ->onError(function (Response $response, Command $command) { if ($response->status() === 401) { $command->error( @@ -46,5 +58,29 @@ public function register(): void { $this->app->singleton(FlareUrlResolver::class); $this->app->singleton(CredentialStore::class); + + $this->app->singleton(OAuthEndpoints::class); + + $this->app->singleton(OAuthHttpClient::class, fn ($app) => new OAuthHttpClient( + $app->make(OAuthEndpoints::class), + (string) config('flare.oauth.client_id'), + )); + + $this->app->singleton(TokenRefresher::class, fn ($app) => new TokenRefresher( + $app->make(OAuthHttpClient::class), + (int) config('flare.oauth.refresh_threshold_seconds', 60), + )); + + $this->app->bind(PkceLoginFlow::class, fn ($app) => new PkceLoginFlow( + $app->make(OAuthHttpClient::class), + $app->make(OAuthEndpoints::class), + (string) config('flare.oauth.client_id'), + (array) config('flare.oauth.scopes', ['read', 'write', 'admin']), + )); + + $this->app->bind(DeviceLoginFlow::class, fn ($app) => new DeviceLoginFlow( + $app->make(OAuthHttpClient::class), + (array) config('flare.oauth.scopes', ['read', 'write', 'admin']), + )); } } diff --git a/app/Services/CredentialStore.php b/app/Services/CredentialStore.php index 51e5e07..3beb424 100644 --- a/app/Services/CredentialStore.php +++ b/app/Services/CredentialStore.php @@ -2,6 +2,10 @@ namespace App\Services; +use App\Services\OAuth\OAuthException; +use App\Services\OAuth\TokenRecord; +use App\Services\OAuth\TokenRefresher; + class CredentialStore { private string $configPath; @@ -16,17 +20,84 @@ public function __construct( public function getToken(): ?string { - return $this->readTokens()[$this->urlResolver->getHostKey()] ?? null; + $entry = $this->readEntries()[$this->urlResolver->getHostKey()] ?? null; + + if (is_string($entry)) { + return $entry; + } + + if (TokenRecord::looksLikeRecord($entry)) { + return TokenRecord::fromArray($entry)->accessToken; + } + + return null; } public function setToken(string $token): void { - $this->ensureConfigDirectoryExists(); + $this->writeEntry($this->urlResolver->getHostKey(), $token); + } + + public function getRecord(): ?TokenRecord + { + $entry = $this->readEntries()[$this->urlResolver->getHostKey()] ?? null; + + if (! TokenRecord::looksLikeRecord($entry)) { + return null; + } + + return TokenRecord::fromArray($entry); + } + + public function getAccessToken(): ?string + { + $entry = $this->readEntries()[$this->urlResolver->getHostKey()] ?? null; - $tokens = $this->readTokens(); - $tokens[$this->urlResolver->getHostKey()] = $token; + if (is_string($entry)) { + return $entry; + } + + if (! TokenRecord::looksLikeRecord($entry)) { + return null; + } + + $record = TokenRecord::fromArray($entry); + + return $this->withConfigLock(function () use ($record) { + $current = $this->getRecord() ?? $record; + $refreshed = app(TokenRefresher::class)->refreshIfNeeded($current); + + if ($refreshed !== $current) { + $this->writeEntry($this->urlResolver->getHostKey(), $refreshed->toArray()); + } + + return $refreshed->accessToken; + }); + } + + public function forceRefresh(): bool + { + $record = $this->getRecord(); + + if ($record === null) { + return false; + } + + return $this->withConfigLock(function () use ($record) { + try { + $refreshed = app(TokenRefresher::class)->refresh($this->getRecord() ?? $record); + $this->writeEntry($this->urlResolver->getHostKey(), $refreshed->toArray()); + + return true; + } catch (OAuthException) { + return false; + } + }); + } - $this->writeTokens($tokens); + public function setRecord(TokenRecord $record): void + { + $this->writeEntry($this->urlResolver->getHostKey(), $record->toArray()); } public function flush(): void @@ -37,10 +108,20 @@ public function flush(): void $this->ensureConfigDirectoryExists(); - $tokens = $this->readTokens(); - unset($tokens[$this->urlResolver->getHostKey()]); + $entries = $this->readEntries(); + unset($entries[$this->urlResolver->getHostKey()]); + + $this->writeEntries($entries); + } + + public function flushAll(): void + { + if (! file_exists($this->configPath)) { + return; + } - $this->writeTokens($tokens); + $this->ensureConfigDirectoryExists(); + $this->writeEntries([]); } /** @@ -48,7 +129,7 @@ public function flush(): void */ public function getConfiguredHosts(): array { - return array_keys($this->readTokens()); + return array_keys($this->readEntries()); } private function ensureConfigDirectoryExists(): void @@ -71,20 +152,20 @@ private function readConfig(): array } /** - * @return array + * @return array> */ - private function readTokens(): array + private function readEntries(): array { $data = $this->readConfig(); - $tokens = $data['tokens'] ?? []; + $entries = $data['tokens'] ?? []; - if (! is_array($tokens)) { - $tokens = []; + if (! is_array($entries)) { + $entries = []; } - $tokens = array_filter( - $tokens, - fn (mixed $token, mixed $host): bool => is_string($host) && is_string($token) && $token !== '', + $entries = array_filter( + $entries, + fn (mixed $entry, mixed $host): bool => is_string($host) && self::isValidEntry($entry), ARRAY_FILTER_USE_BOTH, ); @@ -92,30 +173,75 @@ private function readTokens(): array isset($data['token']) && is_string($data['token']) && $data['token'] !== '' && - ! array_key_exists('flareapp.io', $tokens) + ! array_key_exists('flareapp.io', $entries) ) { - $tokens['flareapp.io'] = $data['token']; + $entries['flareapp.io'] = $data['token']; } - ksort($tokens); + ksort($entries); + + return $entries; + } + + private function writeEntry(string $host, string|array $entry): void + { + $this->ensureConfigDirectoryExists(); - return $tokens; + $entries = $this->readEntries(); + $entries[$host] = $entry; + + $this->writeEntries($entries); } /** - * @param array $tokens + * @param array> $entries */ - private function writeTokens(array $tokens): void + private function writeEntries(array $entries): void { - ksort($tokens); + ksort($entries); $data = $this->readConfig(); unset($data['token']); - $data['tokens'] = $tokens === [] ? (object) [] : $tokens; + $data['tokens'] = $entries === [] ? (object) [] : $entries; file_put_contents( $this->configPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), ); } + + private static function isValidEntry(mixed $entry): bool + { + if (is_string($entry)) { + return $entry !== ''; + } + + return TokenRecord::looksLikeRecord($entry); + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function withConfigLock(callable $callback): mixed + { + $this->ensureConfigDirectoryExists(); + $lockPath = $this->configPath.'.lock'; + $handle = fopen($lockPath, 'c+'); + + if ($handle === false) { + return $callback(); + } + + flock($handle, LOCK_EX); + + try { + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } } diff --git a/app/Services/OAuth/DeviceAuthorization.php b/app/Services/OAuth/DeviceAuthorization.php new file mode 100644 index 0000000..0662e25 --- /dev/null +++ b/app/Services/OAuth/DeviceAuthorization.php @@ -0,0 +1,32 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + deviceCode: (string) ($data['device_code'] ?? ''), + userCode: (string) ($data['user_code'] ?? ''), + verificationUri: (string) ($data['verification_uri'] ?? ''), + verificationUriComplete: isset($data['verification_uri_complete']) + ? (string) $data['verification_uri_complete'] + : null, + expiresIn: (int) ($data['expires_in'] ?? 600), + interval: max(1, (int) ($data['interval'] ?? 5)), + ); + } +} diff --git a/app/Services/OAuth/DeviceLoginFlow.php b/app/Services/OAuth/DeviceLoginFlow.php new file mode 100644 index 0000000..40e246a --- /dev/null +++ b/app/Services/OAuth/DeviceLoginFlow.php @@ -0,0 +1,65 @@ + $scopes + */ + public function __construct( + private readonly OAuthHttpClient $client, + private readonly array $scopes, + ) {} + + /** + * @param callable(DeviceAuthorization): void $announce + * @param ?Closure(int): void $sleeper + * @param ?Closure(): int $clock + */ + public function run( + callable $announce, + ?Closure $sleeper = null, + ?Closure $clock = null, + ): TokenRecord { + $sleeper ??= fn (int $seconds) => sleep($seconds); + $clock ??= fn () => time(); + + $auth = $this->client->requestDeviceCode($this->scopes); + $announce($auth); + + $interval = $auth->interval; + $deadline = $clock() + $auth->expiresIn; + + while ($clock() < $deadline) { + $sleeper($interval); + + $result = $this->client->pollDeviceCode($auth->deviceCode, $this->scopes); + + if ($result->record !== null) { + return $result->record; + } + + if ($result->isPending()) { + continue; + } + + if ($result->isSlowDown()) { + $interval += 5; + + continue; + } + + throw new OAuthException( + "Device authorization failed: {$result->error}" + .($result->errorDescription ? " โ€” {$result->errorDescription}" : ''), + errorCode: $result->error, + errorDescription: $result->errorDescription, + ); + } + + throw new OAuthException('Device code expired before authorization completed.'); + } +} diff --git a/app/Services/OAuth/DevicePollResult.php b/app/Services/OAuth/DevicePollResult.php new file mode 100644 index 0000000..f64295b --- /dev/null +++ b/app/Services/OAuth/DevicePollResult.php @@ -0,0 +1,37 @@ +error === 'authorization_pending'; + } + + public function isSlowDown(): bool + { + return $this->error === 'slow_down'; + } + + public function isFatal(): bool + { + return $this->error !== null && ! $this->isPending() && ! $this->isSlowDown(); + } +} diff --git a/app/Services/OAuth/LocalCallbackServer.php b/app/Services/OAuth/LocalCallbackServer.php new file mode 100644 index 0000000..308e9be --- /dev/null +++ b/app/Services/OAuth/LocalCallbackServer.php @@ -0,0 +1,198 @@ +server = $server; + + $name = stream_socket_get_name($server, false); + + if ($name === false) { + $this->close(); + + throw new RuntimeException('Could not read the bound socket port.'); + } + + $parts = explode(':', $name); + $this->port = (int) end($parts); + $this->redirectUri = "http://127.0.0.1:{$this->port}/callback"; + } + + /** + * Wait for the OAuth provider to redirect the user's browser to our + * callback URL. Returns the parsed query parameters. + * + * @return array + */ + public function awaitCallback(int $timeoutSeconds = 120): array + { + $deadline = microtime(true) + $timeoutSeconds; + + while (microtime(true) < $deadline) { + $remaining = max(1, (int) ceil($deadline - microtime(true))); + $reads = [$this->server]; + $writes = $exc = null; + + $ready = @stream_select($reads, $writes, $exc, $remaining); + + if ($ready === false || $ready === 0) { + continue; + } + + $client = @stream_socket_accept($this->server, 1); + + if ($client === false) { + continue; + } + + $params = $this->readRequest($client); + + $this->writeResponse($client, isset($params['code'])); + + fclose($client); + + if (isset($params['code']) || isset($params['error'])) { + return $params; + } + } + + throw new RuntimeException('Timed out waiting for the OAuth callback.'); + } + + public function close(): void + { + if (is_resource($this->server)) { + @fclose($this->server); + } + + $this->server = null; + } + + public function __destruct() + { + $this->close(); + } + + /** + * @param resource $client + * @return array + */ + private function readRequest($client): array + { + stream_set_timeout($client, 2); + + $request = ''; + $deadline = microtime(true) + 2.0; + + while (microtime(true) < $deadline) { + $chunk = fread($client, 4096); + + if ($chunk === false || $chunk === '') { + break; + } + + $request .= $chunk; + + if (str_contains($request, "\r\n\r\n")) { + break; + } + } + + $firstLine = strtok($request, "\r\n"); + + if ($firstLine === false || ! preg_match('#^GET\s+/[^\s?]*(?:\?([^\s]*))?\s+HTTP/#', $firstLine, $matches)) { + return []; + } + + $params = []; + + if (isset($matches[1])) { + parse_str($matches[1], $params); + } + + return array_map(fn ($v) => is_string($v) ? $v : '', $params); + } + + /** + * @param resource $client + */ + private function writeResponse($client, bool $success): void + { + $body = $success ? self::SUCCESS_HTML : self::ERROR_HTML; + + fwrite( + $client, + "HTTP/1.1 200 OK\r\n" + ."Content-Type: text/html; charset=utf-8\r\n" + .'Content-Length: '.strlen($body)."\r\n" + ."Connection: close\r\n" + ."\r\n" + .$body, + ); + } + + private const SUCCESS_HTML = <<<'HTML' + + + + + Flare CLI โ€” Authentication successful + + + +
+

You're logged in to Flare.

+

You can close this tab and return to your terminal.

+
+ + +HTML; + + private const ERROR_HTML = <<<'HTML' + + + + + Flare CLI โ€” Authentication failed + + + +
+

Authentication failed.

+

Something went wrong. Check your terminal for details.

+
+ + +HTML; +} diff --git a/app/Services/OAuth/OAuthEndpoints.php b/app/Services/OAuth/OAuthEndpoints.php new file mode 100644 index 0000000..08b4ae2 --- /dev/null +++ b/app/Services/OAuth/OAuthEndpoints.php @@ -0,0 +1,37 @@ +base().'/oauth/authorize'; + } + + public function token(): string + { + return $this->base().'/oauth/token'; + } + + public function deviceCode(): string + { + return $this->base().'/oauth/device/code'; + } + + public function deviceVerification(): string + { + return $this->base().'/oauth/device'; + } + + private function base(): string + { + return rtrim($this->urlResolver->getAppUrl(), '/'); + } +} diff --git a/app/Services/OAuth/OAuthException.php b/app/Services/OAuth/OAuthException.php new file mode 100644 index 0000000..ffce0d0 --- /dev/null +++ b/app/Services/OAuth/OAuthException.php @@ -0,0 +1,16 @@ + $requestedScopes + */ + public function exchangeCode( + string $code, + string $codeVerifier, + string $redirectUri, + array $requestedScopes, + ): TokenRecord { + $response = $this->postForm($this->endpoints->token(), [ + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'redirect_uri' => $redirectUri, + 'code' => $code, + 'code_verifier' => $codeVerifier, + ]); + + return $this->recordFromTokenResponse($response, $requestedScopes); + } + + public function refresh(TokenRecord $record): TokenRecord + { + $response = $this->postForm($this->endpoints->token(), [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, + 'refresh_token' => $record->refreshToken, + ]); + + return $this->recordFromTokenResponse($response, $record->scopes, fallbackRefreshToken: $record->refreshToken); + } + + /** + * @param array $scopes + */ + public function requestDeviceCode(array $scopes): DeviceAuthorization + { + $response = $this->postForm($this->endpoints->deviceCode(), [ + 'client_id' => $this->clientId, + 'scope' => implode(' ', $scopes), + ]); + + return DeviceAuthorization::fromArray($response); + } + + /** + * @param array $requestedScopes + */ + public function pollDeviceCode(string $deviceCode, array $requestedScopes): DevicePollResult + { + $response = $this->rawPostForm($this->endpoints->token(), [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $this->clientId, + 'device_code' => $deviceCode, + ]); + + if ($response->successful()) { + return DevicePollResult::success( + $this->recordFromTokenResponse($response->json() ?? [], $requestedScopes), + ); + } + + $body = $response->json() ?? []; + $error = is_string($body['error'] ?? null) ? $body['error'] : 'invalid_request'; + $description = is_string($body['error_description'] ?? null) ? $body['error_description'] : null; + + return DevicePollResult::error($error, $description); + } + + /** + * @param array $form + * @return array + */ + private function postForm(string $url, array $form): array + { + $response = $this->rawPostForm($url, $form); + + if (! $response->successful()) { + $body = $response->json() ?? []; + $error = is_string($body['error'] ?? null) ? $body['error'] : null; + $description = is_string($body['error_description'] ?? null) ? $body['error_description'] : null; + + throw new OAuthException( + message: "OAuth request to {$url} failed (HTTP {$response->status()})" + .($error !== null ? ": {$error}" : '') + .($description !== null ? " โ€” {$description}" : ''), + errorCode: $error, + errorDescription: $description, + ); + } + + return $response->json() ?? []; + } + + /** + * @param array $form + */ + private function rawPostForm(string $url, array $form): Response + { + try { + return Http::asForm()->acceptJson()->post($url, $form); + } catch (ConnectionException $e) { + throw new OAuthException("Could not connect to {$url}: {$e->getMessage()}"); + } + } + + /** + * @param array $response + * @param array $requestedScopes + */ + private function recordFromTokenResponse( + array $response, + array $requestedScopes, + ?string $fallbackRefreshToken = null, + ): TokenRecord { + if (! isset($response['access_token']) || ! is_string($response['access_token'])) { + throw new OAuthException('Token response did not include an access_token.'); + } + + $now = time(); + $expiresIn = isset($response['expires_in']) ? (int) $response['expires_in'] : 0; + + $refreshToken = $response['refresh_token'] ?? $fallbackRefreshToken; + + if (! is_string($refreshToken) || $refreshToken === '') { + throw new OAuthException('Token response did not include a refresh_token.'); + } + + $scopes = $requestedScopes; + + if (isset($response['scope']) && is_string($response['scope']) && $response['scope'] !== '') { + $scopes = array_values(array_filter( + explode(' ', $response['scope']), + static fn (string $scope): bool => $scope !== '', + )); + } + + return TokenRecord::fromArray([ + 'access_token' => $response['access_token'], + 'refresh_token' => $refreshToken, + 'expires_at' => $now + $expiresIn, + 'scopes' => $scopes, + 'client_id' => $this->clientId, + 'obtained_at' => $now, + ]); + } +} diff --git a/app/Services/OAuth/PkceCodes.php b/app/Services/OAuth/PkceCodes.php new file mode 100644 index 0000000..69ffd0a --- /dev/null +++ b/app/Services/OAuth/PkceCodes.php @@ -0,0 +1,26 @@ + $scopes + */ + public function __construct( + private readonly OAuthHttpClient $client, + private readonly OAuthEndpoints $endpoints, + private readonly string $clientId, + private readonly array $scopes, + ) {} + + /** + * @param callable(string): void $openBrowser + * @param callable(string): void $log + */ + public function run( + callable $openBrowser, + callable $log, + ?LocalCallbackServer $server = null, + int $timeoutSeconds = 120, + ): TokenRecord { + $verifier = PkceCodes::verifier(); + $challenge = PkceCodes::challenge($verifier); + $state = PkceCodes::state(); + + $server ??= new LocalCallbackServer; + + try { + $url = $this->buildAuthorizationUrl($server->redirectUri, $challenge, $state); + + $log("Open this URL to continue: {$url}"); + $openBrowser($url); + + $params = $server->awaitCallback($timeoutSeconds); + + if (isset($params['error'])) { + $description = $params['error_description'] ?? ''; + + throw new OAuthException( + "Authorization was denied: {$params['error']}".($description !== '' ? " โ€” {$description}" : ''), + errorCode: $params['error'], + errorDescription: $description !== '' ? $description : null, + ); + } + + if (! isset($params['code'], $params['state'])) { + throw new OAuthException('OAuth callback was missing code or state.'); + } + + if (! hash_equals($state, $params['state'])) { + throw new OAuthException('OAuth callback state did not match. Aborting login.'); + } + + return $this->client->exchangeCode( + code: $params['code'], + codeVerifier: $verifier, + redirectUri: $server->redirectUri, + requestedScopes: $this->scopes, + ); + } finally { + $server->close(); + } + } + + private function buildAuthorizationUrl(string $redirectUri, string $challenge, string $state): string + { + return $this->endpoints->authorize().'?'.http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => implode(' ', $this->scopes), + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ], '', '&', PHP_QUERY_RFC3986); + } + + public static function defaultBrowserOpener(string $url): void + { + $escaped = escapeshellarg($url); + + match (PHP_OS_FAMILY) { + 'Darwin' => exec("open {$escaped} > /dev/null 2>&1 &"), + 'Windows' => exec("start \"\" {$escaped} > nul 2>&1"), + default => exec("xdg-open {$escaped} > /dev/null 2>&1 &"), + }; + } +} diff --git a/app/Services/OAuth/TokenRecord.php b/app/Services/OAuth/TokenRecord.php new file mode 100644 index 0000000..eee6396 --- /dev/null +++ b/app/Services/OAuth/TokenRecord.php @@ -0,0 +1,91 @@ + $scopes + */ + public function __construct( + public readonly string $accessToken, + public readonly string $refreshToken, + public readonly int $expiresAt, + public readonly array $scopes, + public readonly string $clientId, + public readonly int $obtainedAt, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + foreach (['access_token', 'refresh_token', 'expires_at', 'client_id', 'obtained_at'] as $key) { + if (! array_key_exists($key, $data)) { + throw new InvalidArgumentException("Missing OAuth record field: {$key}"); + } + } + + $scopes = $data['scopes'] ?? []; + + if (! is_array($scopes)) { + $scopes = []; + } + + return new self( + accessToken: (string) $data['access_token'], + refreshToken: (string) $data['refresh_token'], + expiresAt: (int) $data['expires_at'], + scopes: array_values(array_filter($scopes, 'is_string')), + clientId: (string) $data['client_id'], + obtainedAt: (int) $data['obtained_at'], + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'type' => self::TYPE, + 'access_token' => $this->accessToken, + 'refresh_token' => $this->refreshToken, + 'expires_at' => $this->expiresAt, + 'scopes' => $this->scopes, + 'client_id' => $this->clientId, + 'obtained_at' => $this->obtainedAt, + ]; + } + + public function isExpiringWithin(int $seconds, ?int $now = null): bool + { + return ($now ?? time()) >= ($this->expiresAt - $seconds); + } + + public function withRefreshed( + string $accessToken, + string $refreshToken, + int $expiresAt, + int $obtainedAt, + ): self { + return new self( + accessToken: $accessToken, + refreshToken: $refreshToken, + expiresAt: $expiresAt, + scopes: $this->scopes, + clientId: $this->clientId, + obtainedAt: $obtainedAt, + ); + } + + public static function looksLikeRecord(mixed $data): bool + { + return is_array($data) && ($data['type'] ?? null) === self::TYPE; + } +} diff --git a/app/Services/OAuth/TokenRefresher.php b/app/Services/OAuth/TokenRefresher.php new file mode 100644 index 0000000..d2ac5fa --- /dev/null +++ b/app/Services/OAuth/TokenRefresher.php @@ -0,0 +1,25 @@ +isExpiringWithin($this->thresholdSeconds, $now)) { + return $record; + } + + return $this->client->refresh($record); + } + + public function refresh(TokenRecord $record): TokenRecord + { + return $this->client->refresh($record); + } +} diff --git a/composer.json b/composer.json index 4bd7de5..1bbd924 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "phpstan/extension-installer": "^1.4", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", - "spatie/laravel-openapi-cli": "^1.0.2" + "spatie/laravel-openapi-cli": "^1.3.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 2b8d91d..a115f4f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6bf26acd9cd7ab0968a56dfc7793525e", + "content-hash": "12c1e584603ed529a3871a4083af3811", "packages": [], "packages-dev": [ { @@ -6956,16 +6956,16 @@ }, { "name": "spatie/laravel-openapi-cli", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-openapi-cli.git", - "reference": "0c4e1cb0d091f8c66be3596522d363f5fbe91626" + "reference": "1217cbb74bd3cac2b288831c3823f49457e70233" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-openapi-cli/zipball/0c4e1cb0d091f8c66be3596522d363f5fbe91626", - "reference": "0c4e1cb0d091f8c66be3596522d363f5fbe91626", + "url": "https://api.github.com/repos/spatie/laravel-openapi-cli/zipball/1217cbb74bd3cac2b288831c3823f49457e70233", + "reference": "1217cbb74bd3cac2b288831c3823f49457e70233", "shasum": "" }, "require": { @@ -7024,7 +7024,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-openapi-cli/issues", - "source": "https://github.com/spatie/laravel-openapi-cli/tree/1.2.0" + "source": "https://github.com/spatie/laravel-openapi-cli/tree/1.3.0" }, "funding": [ { @@ -7032,7 +7032,7 @@ "type": "github" } ], - "time": "2026-04-28T06:44:50+00:00" + "time": "2026-05-22T12:26:40+00:00" }, { "name": "spatie/laravel-package-tools", diff --git a/config/flare.php b/config/flare.php new file mode 100644 index 0000000..a86aeb2 --- /dev/null +++ b/config/flare.php @@ -0,0 +1,27 @@ + [ + 'client_id' => env('FLARE_OAUTH_CLIENT_ID', '9d000000-0000-4000-8000-000000000001'), + + /* + * The CLI requests `admin` so that project/team-management commands + * (create-project, delete-project, remove-team-user) work after login. + * The Flare consent screen always lets the user narrow this at grant time. + */ + 'scopes' => ['read', 'write', 'admin'], + 'refresh_threshold_seconds' => 60, + ], +]; diff --git a/tests/Feature/CredentialStoreTest.php b/tests/Feature/CredentialStoreTest.php index f082e42..f164f92 100644 --- a/tests/Feature/CredentialStoreTest.php +++ b/tests/Feature/CredentialStoreTest.php @@ -2,6 +2,21 @@ use App\Services\CredentialStore; use App\Services\FlareUrlResolver; +use App\Services\OAuth\OAuthException; +use App\Services\OAuth\TokenRecord; +use App\Services\OAuth\TokenRefresher; + +function makeRecord(array $overrides = []): TokenRecord +{ + return TokenRecord::fromArray(array_merge([ + 'access_token' => 'access-abc', + 'refresh_token' => 'refresh-xyz', + 'expires_at' => 1_700_000_000, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => 1_699_999_000, + ], $overrides)); +} beforeEach(function () { $this->tempDir = sys_get_temp_dir().'/flare-cli-test-'.uniqid(); @@ -28,12 +43,15 @@ } // Clean up temp directory - $configFile = $this->tempDir.'/.flare/config.json'; - if (file_exists($configFile)) { - unlink($configFile); + $configDir = $this->tempDir.'/.flare'; + foreach (['config.json', 'config.json.lock'] as $name) { + $path = "{$configDir}/{$name}"; + if (file_exists($path)) { + unlink($path); + } } - if (is_dir($this->tempDir.'/.flare')) { - rmdir($this->tempDir.'/.flare'); + if (is_dir($configDir)) { + rmdir($configDir); } if (is_dir($this->tempDir)) { rmdir($this->tempDir); @@ -141,3 +159,168 @@ expect($stagingStore->getToken())->toBeNull(); expect($stagingStore->getConfiguredHosts())->toBe(['flareapp.io']); }); + +it('stores and retrieves an OAuth record', function () { + $record = makeRecord(); + + $this->store->setRecord($record); + + expect($this->store->getRecord())->toEqual($record); + expect($this->store->getToken())->toBe('access-abc'); +}); + +it('returns the access token via getToken for OAuth records', function () { + $this->store->setRecord(makeRecord(['access_token' => 'fresh-access'])); + + expect($this->store->getToken())->toBe('fresh-access'); + expect($this->store->getRecord()?->accessToken)->toBe('fresh-access'); +}); + +it('returns null from getRecord when only a legacy string is stored', function () { + $this->store->setToken('plain-pat-token'); + + expect($this->store->getRecord())->toBeNull(); + expect($this->store->getToken())->toBe('plain-pat-token'); +}); + +it('allows a string token and an OAuth record to coexist for different hosts', function () { + $this->store->setToken('production-pat'); + + putenv('FLARE_BASE_URL=https://passport-oauth.test/api'); + $_SERVER['FLARE_BASE_URL'] = 'https://passport-oauth.test/api'; + + $oauthStore = new CredentialStore(new FlareUrlResolver); + $oauthStore->setRecord(makeRecord()); + + expect($oauthStore->getRecord()?->accessToken)->toBe('access-abc'); + expect($oauthStore->getToken())->toBe('access-abc'); + + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); + + $productionStore = new CredentialStore(new FlareUrlResolver); + expect($productionStore->getToken())->toBe('production-pat'); + expect($productionStore->getRecord())->toBeNull(); + expect($productionStore->getConfiguredHosts())->toBe(['flareapp.io', 'passport-oauth.test']); +}); + +it('replaces an OAuth record when setToken is called for the same host', function () { + $this->store->setRecord(makeRecord()); + $this->store->setToken('replacing-pat'); + + expect($this->store->getRecord())->toBeNull(); + expect($this->store->getToken())->toBe('replacing-pat'); +}); + +it('replaces a string token when setRecord is called for the same host', function () { + $this->store->setToken('old-pat'); + $this->store->setRecord(makeRecord()); + + expect($this->store->getToken())->toBe('access-abc'); + expect($this->store->getRecord())->not->toBeNull(); +}); + +it('flushes an OAuth record', function () { + $this->store->setRecord(makeRecord()); + $this->store->flush(); + + expect($this->store->getRecord())->toBeNull(); + expect($this->store->getToken())->toBeNull(); +}); + +it('ignores entries that are neither strings nor OAuth records', function () { + mkdir($this->tempDir.'/.flare', 0755, true); + + file_put_contents( + $this->tempDir.'/.flare/config.json', + json_encode([ + 'tokens' => [ + 'flareapp.io' => 'valid-pat', + 'bogus.test' => 42, + 'half-baked.test' => ['type' => 'something-else'], + ], + ], JSON_PRETTY_PRINT), + ); + + expect($this->store->getConfiguredHosts())->toBe(['flareapp.io']); +}); + +it('returns the stored string verbatim via getAccessToken for legacy tokens', function () { + $this->store->setToken('legacy-pat'); + + expect($this->store->getAccessToken())->toBe('legacy-pat'); +}); + +it('refreshes and writes back when getAccessToken finds a near-expiry OAuth record', function () { + $stale = makeRecord(['expires_at' => time() + 10, 'access_token' => 'stale']); + $fresh = makeRecord(['expires_at' => time() + 99999, 'access_token' => 'fresh', 'refresh_token' => 'rotated']); + + $this->store->setRecord($stale); + + $refresher = Mockery::mock(TokenRefresher::class); + $refresher->shouldReceive('refreshIfNeeded') + ->once() + ->andReturn($fresh); + + app()->instance(TokenRefresher::class, $refresher); + + expect($this->store->getAccessToken())->toBe('fresh'); + expect($this->store->getRecord()?->accessToken)->toBe('fresh'); + expect($this->store->getRecord()?->refreshToken)->toBe('rotated'); +}); + +it('does not write back when refreshIfNeeded returns the same record', function () { + $record = makeRecord(['expires_at' => time() + 99999]); + $this->store->setRecord($record); + + $configPath = $this->tempDir.'/.flare/config.json'; + $originalMtime = filemtime($configPath); + + $refresher = Mockery::mock(TokenRefresher::class); + $refresher->shouldReceive('refreshIfNeeded') + ->once() + ->andReturnUsing(fn ($current) => $current); + + app()->instance(TokenRefresher::class, $refresher); + + clearstatcache(); + sleep(1); // ensure mtime would change if a write occurred + $this->store->getAccessToken(); + + clearstatcache(); + expect(filemtime($configPath))->toBe($originalMtime); +}); + +it('returns true and persists rotated tokens on forceRefresh success', function () { + $stale = makeRecord(['access_token' => 'stale']); + $fresh = makeRecord(['access_token' => 'fresh', 'refresh_token' => 'rotated']); + + $this->store->setRecord($stale); + + $refresher = Mockery::mock(TokenRefresher::class); + $refresher->shouldReceive('refresh')->once()->andReturn($fresh); + + app()->instance(TokenRefresher::class, $refresher); + + expect($this->store->forceRefresh())->toBeTrue(); + expect($this->store->getRecord()?->accessToken)->toBe('fresh'); +}); + +it('returns false from forceRefresh when no OAuth record is stored', function () { + $this->store->setToken('legacy-pat'); + + expect($this->store->forceRefresh())->toBeFalse(); +}); + +it('returns false from forceRefresh when the refresh call fails', function () { + $this->store->setRecord(makeRecord()); + + $refresher = Mockery::mock(TokenRefresher::class); + $refresher->shouldReceive('refresh') + ->once() + ->andThrow(new OAuthException('refresh failed', 'invalid_grant')); + + app()->instance(TokenRefresher::class, $refresher); + + expect($this->store->forceRefresh())->toBeFalse(); +}); diff --git a/tests/Feature/DeviceLoginFlowTest.php b/tests/Feature/DeviceLoginFlowTest.php new file mode 100644 index 0000000..afef664 --- /dev/null +++ b/tests/Feature/DeviceLoginFlowTest.php @@ -0,0 +1,115 @@ + 'dev-code', + 'user_code' => 'ABCD-EFGH', + 'verification_uri' => 'https://passport-oauth.test/oauth/device', + 'expires_in' => $expiresIn, + 'interval' => $interval, + ]); +} + +function deviceTokenRecord(): TokenRecord +{ + return TokenRecord::fromArray([ + 'access_token' => 'device-access', + 'refresh_token' => 'device-refresh', + 'expires_at' => time() + 1296000, + 'scopes' => ['read'], + 'client_id' => 'client-uuid', + 'obtained_at' => time(), + ]); +} + +it('polls until tokens arrive and returns the TokenRecord', function () { + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('requestDeviceCode')->once()->andReturn(deviceAuth(interval: 5)); + $client->shouldReceive('pollDeviceCode') + ->times(3) + ->andReturn( + DevicePollResult::error('authorization_pending'), + DevicePollResult::error('authorization_pending'), + DevicePollResult::success(deviceTokenRecord()), + ); + + $sleepCalls = []; + $sleeper = function (int $seconds) use (&$sleepCalls) { + $sleepCalls[] = $seconds; + }; + + $announced = false; + $announce = function (DeviceAuthorization $auth) use (&$announced) { + $announced = true; + expect($auth->userCode)->toBe('ABCD-EFGH'); + }; + + $flow = new DeviceLoginFlow($client, ['read']); + $record = $flow->run($announce, $sleeper); + + expect($announced)->toBeTrue(); + expect($record->accessToken)->toBe('device-access'); + expect($sleepCalls)->toBe([5, 5, 5]); +}); + +it('increases the polling interval on slow_down', function () { + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('requestDeviceCode')->once()->andReturn(deviceAuth(interval: 5)); + $client->shouldReceive('pollDeviceCode') + ->times(3) + ->andReturn( + DevicePollResult::error('slow_down'), + DevicePollResult::error('authorization_pending'), + DevicePollResult::success(deviceTokenRecord()), + ); + + $sleepCalls = []; + $sleeper = function (int $seconds) use (&$sleepCalls) { + $sleepCalls[] = $seconds; + }; + + (new DeviceLoginFlow($client, ['read']))->run(fn () => null, $sleeper); + + expect($sleepCalls)->toBe([5, 10, 10]); +}); + +it('throws on a fatal device-flow error', function () { + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('requestDeviceCode')->once()->andReturn(deviceAuth()); + $client->shouldReceive('pollDeviceCode') + ->once() + ->andReturn(DevicePollResult::error('access_denied', 'user denied')); + + expect(fn () => (new DeviceLoginFlow($client, ['read']))->run( + fn () => null, + fn () => null, + ))->toThrow(OAuthException::class, 'access_denied'); +}); + +it('throws when the device code expires before tokens arrive', function () { + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('requestDeviceCode')->once()->andReturn(deviceAuth(interval: 1, expiresIn: 2)); + $client->shouldReceive('pollDeviceCode')->andReturn(DevicePollResult::error('authorization_pending')); + + $now = 1_000_000; + $clock = function () use (&$now) { + return $now; + }; + $sleeper = function (int $seconds) use (&$now) { + $now += $seconds; + }; + + expect(fn () => (new DeviceLoginFlow($client, ['read']))->run( + fn () => null, + Closure::fromCallable($sleeper), + Closure::fromCallable($clock), + ))->toThrow(OAuthException::class, 'expired'); +}); diff --git a/tests/Feature/LocalCallbackServerTest.php b/tests/Feature/LocalCallbackServerTest.php new file mode 100644 index 0000000..1b9a0ba --- /dev/null +++ b/tests/Feature/LocalCallbackServerTest.php @@ -0,0 +1,61 @@ +port)->toBeGreaterThan(0); + expect($server->redirectUri)->toMatch('#^http://127\.0\.0\.1:\d+/callback$#'); + } finally { + $server->close(); + } +}); + +it('parses code and state from a real HTTP GET request', function () { + $server = new LocalCallbackServer; + + try { + $client = stream_socket_client("tcp://127.0.0.1:{$server->port}"); + expect($client)->not->toBeFalse(); + + fwrite( + $client, + "GET /callback?code=auth-code-123&state=state-xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + $params = $server->awaitCallback(5); + $response = stream_get_contents($client); + fclose($client); + + expect($params['code'])->toBe('auth-code-123'); + expect($params['state'])->toBe('state-xyz'); + expect($response)->toContain('HTTP/1.1 200 OK'); + expect($response)->toContain("You're logged in to Flare."); + } finally { + $server->close(); + } +}); + +it('returns OAuth error params verbatim and renders the failure page', function () { + $server = new LocalCallbackServer; + + try { + $client = stream_socket_client("tcp://127.0.0.1:{$server->port}"); + fwrite( + $client, + "GET /callback?error=access_denied&error_description=user+said+no HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + + $params = $server->awaitCallback(5); + $response = stream_get_contents($client); + fclose($client); + + expect($params['error'])->toBe('access_denied'); + expect($params['error_description'])->toBe('user said no'); + expect($response)->toContain('Authentication failed.'); + } finally { + $server->close(); + } +}); diff --git a/tests/Feature/LoginCommandTest.php b/tests/Feature/LoginCommandTest.php index 46b9092..b12cea0 100644 --- a/tests/Feature/LoginCommandTest.php +++ b/tests/Feature/LoginCommandTest.php @@ -2,6 +2,11 @@ use App\Services\CredentialStore; use App\Services\FlareUrlResolver; +use App\Services\OAuth\DeviceAuthorization; +use App\Services\OAuth\DeviceLoginFlow; +use App\Services\OAuth\OAuthException; +use App\Services\OAuth\PkceLoginFlow; +use App\Services\OAuth\TokenRecord; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; @@ -27,19 +32,34 @@ $_SERVER['FLARE_BASE_URL'] = $this->originalBaseUrl; } - $configFile = $this->tempDir.'/.flare/config.json'; - if (file_exists($configFile)) { - unlink($configFile); + $configDir = $this->tempDir.'/.flare'; + foreach (['config.json', 'config.json.lock'] as $name) { + $path = "{$configDir}/{$name}"; + if (file_exists($path)) { + unlink($path); + } } - if (is_dir($this->tempDir.'/.flare')) { - rmdir($this->tempDir.'/.flare'); + if (is_dir($configDir)) { + rmdir($configDir); } if (is_dir($this->tempDir)) { rmdir($this->tempDir); } }); -it('stores credentials on successful login', function () { +function loginRecord(array $overrides = []): TokenRecord +{ + return TokenRecord::fromArray(array_merge([ + 'access_token' => 'pkce-access', + 'refresh_token' => 'pkce-refresh', + 'expires_at' => time() + 1296000, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => time(), + ], $overrides)); +} + +it('stores credentials on successful login with --token', function () { Http::fake([ 'flareapp.io/api/me' => Http::response([ 'id' => 20, @@ -49,7 +69,7 @@ ]), ]); - $this->artisan('login') + $this->artisan('login --token') ->expectsQuestion('Enter your Flare API token', 'valid-token-123') ->expectsOutputToContain('Successfully logged in as alex@spatie.be') ->assertExitCode(0); @@ -57,12 +77,12 @@ expect($this->store->getToken())->toBe('valid-token-123'); }); -it('shows error and does not store token on invalid token', function () { +it('shows error and does not store token on invalid --token input', function () { Http::fake([ 'flareapp.io/api/me' => Http::response(['error' => 'Unauthorized'], 401), ]); - $this->artisan('login') + $this->artisan('login --token') ->expectsQuestion('Enter your Flare API token', 'invalid-token') ->expectsOutput('Invalid API token.') ->assertExitCode(1); @@ -70,7 +90,7 @@ expect($this->store->getToken())->toBeNull(); }); -it('validates the token against the active base URL', function () { +it('validates the --token against the active base URL', function () { putenv('FLARE_BASE_URL=https://ingress-staging.flareapp.io/api/'); $_SERVER['FLARE_BASE_URL'] = 'https://ingress-staging.flareapp.io/api/'; @@ -80,7 +100,7 @@ ]), ]); - $this->artisan('login') + $this->artisan('login --token') ->expectsQuestion('Enter your Flare API token', 'staging-token-123') ->expectsOutputToContain('https://staging.flareapp.io/api') ->expectsOutputToContain('https://staging.flareapp.io/account/api-tokens') @@ -90,17 +110,144 @@ expect($this->store->getToken())->toBe('staging-token-123'); }); -it('shows connection error on network failure', function () { +it('shows connection error on --token network failure', function () { Http::fake([ 'flareapp.io/api/me' => function () { throw new ConnectionException('Connection refused'); }, ]); - $this->artisan('login') + $this->artisan('login --token') ->expectsQuestion('Enter your Flare API token', 'some-token') ->expectsOutput('Could not connect to Flare. Please check your internet connection.') ->assertExitCode(1); expect($this->store->getToken())->toBeNull(); }); + +it('warns when --token replaces an existing OAuth record', function () { + $this->store->setRecord(loginRecord()); + + Http::fake([ + 'flareapp.io/api/me' => Http::response(['email' => 'alex@spatie.be']), + ]); + + $this->artisan('login --token') + ->expectsQuestion('Enter your Flare API token', 'replacement-pat') + ->expectsOutputToContain('A browser-based OAuth session already exists') + ->expectsOutputToContain('Successfully logged in as alex@spatie.be') + ->assertExitCode(0); + + expect($this->store->getToken())->toBe('replacement-pat'); + expect($this->store->getRecord())->toBeNull(); +}); + +it('completes the PKCE browser flow and stores the OAuth record', function () { + $record = loginRecord(['access_token' => 'browser-access']); + + Http::fake([ + 'flareapp.io/api/me' => Http::response(['email' => 'alex@spatie.be']), + ]); + + $flow = Mockery::mock(PkceLoginFlow::class); + $flow->shouldReceive('run')->once()->andReturn($record); + $this->app->instance(PkceLoginFlow::class, $flow); + + $this->artisan('login') + ->expectsOutputToContain('Opening your browser') + ->expectsOutputToContain('Successfully logged in as alex@spatie.be') + ->assertExitCode(0); + + expect($this->store->getRecord()?->accessToken)->toBe('browser-access'); +}); + +it('reports email as unknown if /me fails after a successful PKCE exchange', function () { + Http::fake([ + 'flareapp.io/api/me' => Http::response([], 500), + ]); + + $flow = Mockery::mock(PkceLoginFlow::class); + $flow->shouldReceive('run')->once()->andReturn(loginRecord()); + $this->app->instance(PkceLoginFlow::class, $flow); + + $this->artisan('login') + ->expectsOutputToContain('Successfully logged in as unknown') + ->assertExitCode(0); + + expect($this->store->getRecord())->not->toBeNull(); +}); + +it('shows the OAuth error and does not store a record when PKCE fails', function () { + $flow = Mockery::mock(PkceLoginFlow::class); + $flow->shouldReceive('run')->once()->andThrow(new OAuthException('state did not match')); + $this->app->instance(PkceLoginFlow::class, $flow); + + $this->artisan('login') + ->expectsOutputToContain('state did not match') + ->assertExitCode(1); + + expect($this->store->getRecord())->toBeNull(); +}); + +it('completes the device code flow and stores the OAuth record', function () { + Http::fake([ + 'flareapp.io/api/me' => Http::response(['email' => 'alex@spatie.be']), + ]); + + $device = Mockery::mock(DeviceLoginFlow::class); + $device->shouldReceive('run') + ->once() + ->andReturnUsing(function ($announce) { + $announce(DeviceAuthorization::fromArray([ + 'device_code' => 'dev-code', + 'user_code' => 'ABCD-EFGH', + 'verification_uri' => 'https://flareapp.io/oauth/device', + 'expires_in' => 600, + 'interval' => 5, + ])); + + return loginRecord(['access_token' => 'device-access']); + }); + $this->app->instance(DeviceLoginFlow::class, $device); + + $this->artisan('login --device') + ->expectsOutputToContain('ABCD-EFGH') + ->expectsOutputToContain('https://flareapp.io/oauth/device') + ->expectsOutputToContain('Successfully logged in as alex@spatie.be') + ->assertExitCode(0); + + expect($this->store->getRecord()?->accessToken)->toBe('device-access'); +}); + +it('reports the error and does not store a record when device flow fails', function () { + $device = Mockery::mock(DeviceLoginFlow::class); + $device->shouldReceive('run')->once()->andThrow(new OAuthException('access_denied')); + $this->app->instance(DeviceLoginFlow::class, $device); + + $this->artisan('login --device') + ->expectsOutputToContain('access_denied') + ->assertExitCode(1); + + expect($this->store->getRecord())->toBeNull(); +}); + +it('falls back to device flow when the terminal is non-interactive', function () { + Http::fake([ + 'flareapp.io/api/me' => Http::response(['email' => 'alex@spatie.be']), + ]); + + $device = Mockery::mock(DeviceLoginFlow::class); + $device->shouldReceive('run')->once()->andReturn(loginRecord(['access_token' => 'fallback-access'])); + $this->app->instance(DeviceLoginFlow::class, $device); + + $pkce = Mockery::mock(PkceLoginFlow::class); + $pkce->shouldNotReceive('run'); + $this->app->instance(PkceLoginFlow::class, $pkce); + + $this->artisan('login --no-interaction') + ->expectsOutputToContain('Non-interactive terminal detected') + ->expectsOutputToContain('Successfully logged in') + ->assertExitCode(0); + + expect($this->store->getRecord()?->accessToken)->toBe('fallback-access'); +}); diff --git a/tests/Feature/LogoutCommandTest.php b/tests/Feature/LogoutCommandTest.php index 4641c6f..96be89e 100644 --- a/tests/Feature/LogoutCommandTest.php +++ b/tests/Feature/LogoutCommandTest.php @@ -68,3 +68,31 @@ expect((new CredentialStore(new FlareUrlResolver))->getToken())->toBe('production-token'); }); + +it('clears every stored host with --all', function () { + $this->store->setToken('production-token'); + + putenv('FLARE_BASE_URL=https://ingress-staging.flareapp.io/api'); + $_SERVER['FLARE_BASE_URL'] = 'https://ingress-staging.flareapp.io/api'; + + $stagingStore = new CredentialStore(new FlareUrlResolver); + $stagingStore->setToken('staging-token'); + $this->app->instance(CredentialStore::class, $stagingStore); + + $this->artisan('logout --all') + ->expectsOutput('Removed credentials for: flareapp.io, staging.flareapp.io.') + ->assertExitCode(0); + + expect($stagingStore->getToken())->toBeNull(); + + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); + + expect((new CredentialStore(new FlareUrlResolver))->getToken())->toBeNull(); +}); + +it('reports nothing to remove on logout --all when no credentials are stored', function () { + $this->artisan('logout --all') + ->expectsOutput('No stored credentials to remove.') + ->assertExitCode(0); +}); diff --git a/tests/Feature/OAuthHttpClientTest.php b/tests/Feature/OAuthHttpClientTest.php new file mode 100644 index 0000000..893dd58 --- /dev/null +++ b/tests/Feature/OAuthHttpClientTest.php @@ -0,0 +1,217 @@ +client = new OAuthHttpClient( + new OAuthEndpoints(new FlareUrlResolver), + clientId: 'client-uuid', + ); +}); + +afterEach(function () { + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); +}); + +it('exchanges an authorization code for a TokenRecord', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'access_token' => 'new-access', + 'refresh_token' => 'new-refresh', + 'expires_in' => 1296000, + 'token_type' => 'Bearer', + ]), + ]); + + $record = $this->client->exchangeCode( + code: 'auth-code', + codeVerifier: 'verifier-string', + redirectUri: 'http://127.0.0.1:54321/callback', + requestedScopes: ['read', 'write'], + ); + + expect($record)->toBeInstanceOf(TokenRecord::class); + expect($record->accessToken)->toBe('new-access'); + expect($record->refreshToken)->toBe('new-refresh'); + expect($record->scopes)->toBe(['read', 'write']); + expect($record->clientId)->toBe('client-uuid'); + expect($record->expiresAt)->toBeGreaterThan(time()); + + Http::assertSent(function ($request) { + return $request->url() === 'https://passport-oauth.test/oauth/token' + && $request['grant_type'] === 'authorization_code' + && $request['client_id'] === 'client-uuid' + && $request['code'] === 'auth-code' + && $request['code_verifier'] === 'verifier-string' + && $request['redirect_uri'] === 'http://127.0.0.1:54321/callback' + && ! isset($request['client_secret']); + }); +}); + +it('refreshes an access token and preserves rotated refresh tokens', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'access_token' => 'refreshed-access', + 'refresh_token' => 'rotated-refresh', + 'expires_in' => 1296000, + ]), + ]); + + $original = TokenRecord::fromArray([ + 'access_token' => 'old', + 'refresh_token' => 'old-refresh', + 'expires_at' => time() - 100, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => time() - 200, + ]); + + $refreshed = $this->client->refresh($original); + + expect($refreshed->accessToken)->toBe('refreshed-access'); + expect($refreshed->refreshToken)->toBe('rotated-refresh'); + expect($refreshed->scopes)->toBe(['read', 'write']); + + Http::assertSent(fn ($request) => $request['grant_type'] === 'refresh_token' + && $request['refresh_token'] === 'old-refresh'); +}); + +it('keeps the existing refresh token when the response does not rotate it', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'access_token' => 'refreshed-access', + 'expires_in' => 1296000, + ]), + ]); + + $original = TokenRecord::fromArray([ + 'access_token' => 'old', + 'refresh_token' => 'sticky-refresh', + 'expires_at' => time() - 100, + 'scopes' => ['read'], + 'client_id' => 'client-uuid', + 'obtained_at' => time() - 200, + ]); + + $refreshed = $this->client->refresh($original); + + expect($refreshed->refreshToken)->toBe('sticky-refresh'); +}); + +it('throws an OAuthException with the server error code on a 400 token response', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The refresh token is invalid.', + ], 400), + ]); + + $record = TokenRecord::fromArray([ + 'access_token' => 'a', + 'refresh_token' => 'r', + 'expires_at' => time(), + 'scopes' => [], + 'client_id' => 'client-uuid', + 'obtained_at' => time(), + ]); + + try { + $this->client->refresh($record); + $this->fail('Expected OAuthException was not thrown'); + } catch (OAuthException $e) { + expect($e->errorCode)->toBe('invalid_grant'); + expect($e->errorDescription)->toBe('The refresh token is invalid.'); + } +}); + +it('requests a device code and parses the response', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/device/code' => Http::response([ + 'device_code' => 'dev-code-123', + 'user_code' => 'ABCD-EFGH', + 'verification_uri' => 'https://passport-oauth.test/oauth/device', + 'verification_uri_complete' => 'https://passport-oauth.test/oauth/device?code=ABCD-EFGH', + 'expires_in' => 600, + 'interval' => 5, + ]), + ]); + + $auth = $this->client->requestDeviceCode(['read', 'write']); + + expect($auth->deviceCode)->toBe('dev-code-123'); + expect($auth->userCode)->toBe('ABCD-EFGH'); + expect($auth->verificationUri)->toBe('https://passport-oauth.test/oauth/device'); + expect($auth->interval)->toBe(5); + expect($auth->expiresIn)->toBe(600); + + Http::assertSent(fn ($request) => $request['client_id'] === 'client-uuid' + && $request['scope'] === 'read write'); +}); + +it('polls the token endpoint and returns success when tokens arrive', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'access_token' => 'device-access', + 'refresh_token' => 'device-refresh', + 'expires_in' => 1296000, + ]), + ]); + + $result = $this->client->pollDeviceCode('dev-code-123', ['read']); + + expect($result)->toBeInstanceOf(DevicePollResult::class); + expect($result->isPending())->toBeFalse(); + expect($result->record?->accessToken)->toBe('device-access'); +}); + +it('returns a pending poll result on authorization_pending', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'error' => 'authorization_pending', + ], 400), + ]); + + $result = $this->client->pollDeviceCode('dev-code-123', ['read']); + + expect($result->isPending())->toBeTrue(); + expect($result->isFatal())->toBeFalse(); +}); + +it('returns a slow_down poll result distinctly from pending', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'error' => 'slow_down', + ], 400), + ]); + + $result = $this->client->pollDeviceCode('dev-code-123', ['read']); + + expect($result->isSlowDown())->toBeTrue(); + expect($result->isPending())->toBeFalse(); + expect($result->isFatal())->toBeFalse(); +}); + +it('flags fatal device-flow errors', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'error' => 'access_denied', + 'error_description' => 'The user denied the request.', + ], 400), + ]); + + $result = $this->client->pollDeviceCode('dev-code-123', ['read']); + + expect($result->isFatal())->toBeTrue(); + expect($result->error)->toBe('access_denied'); + expect($result->errorDescription)->toBe('The user denied the request.'); +}); diff --git a/tests/Feature/OpenApiRegistrationTest.php b/tests/Feature/OpenApiRegistrationTest.php index 12595aa..392ceb9 100644 --- a/tests/Feature/OpenApiRegistrationTest.php +++ b/tests/Feature/OpenApiRegistrationTest.php @@ -3,6 +3,7 @@ use App\Providers\AppServiceProvider; use App\Services\CredentialStore; use App\Services\FlareUrlResolver; +use GuzzleHttp\Psr7\Response; use Spatie\OpenApiCli\OpenApiCli; beforeEach(function () { @@ -65,3 +66,49 @@ expect(($registration->getAuthCallable())())->toBe('late-staging-token'); }); + +it('retries on 401 by calling forceRefresh and skips other status codes', function () { + $store = Mockery::mock(CredentialStore::class)->makePartial(); + $store->shouldReceive('forceRefresh')->once()->andReturn(true); + + OpenApiCli::clearRegistrations(); + app()->forgetInstance(CredentialStore::class); + $this->app->instance(CredentialStore::class, $store); + + (new AppServiceProvider($this->app))->boot(); + + $registration = OpenApiCli::getRegistrations()[0]; + $retry = $registration->getRetryCallable(); + + expect($retry)->not->toBeNull(); + expect($registration->getRetryMaxRetries())->toBe(1); + + $response500 = new Illuminate\Http\Client\Response( + new Response(500, [], ''), + ); + $response401 = new Illuminate\Http\Client\Response( + new Response(401, [], ''), + ); + + expect($retry($response500))->toBeFalse(); + expect($retry($response401))->toBeTrue(); +}); + +it('does not retry on 401 when forceRefresh fails', function () { + $store = Mockery::mock(CredentialStore::class)->makePartial(); + $store->shouldReceive('forceRefresh')->once()->andReturn(false); + + OpenApiCli::clearRegistrations(); + app()->forgetInstance(CredentialStore::class); + $this->app->instance(CredentialStore::class, $store); + + (new AppServiceProvider($this->app))->boot(); + + $retry = OpenApiCli::getRegistrations()[0]->getRetryCallable(); + + $response401 = new Illuminate\Http\Client\Response( + new Response(401, [], ''), + ); + + expect($retry($response401))->toBeFalse(); +}); diff --git a/tests/Feature/PkceLoginFlowTest.php b/tests/Feature/PkceLoginFlowTest.php new file mode 100644 index 0000000..ec69e6c --- /dev/null +++ b/tests/Feature/PkceLoginFlowTest.php @@ -0,0 +1,109 @@ +endpoints = new OAuthEndpoints(new FlareUrlResolver); + $this->httpClient = new OAuthHttpClient($this->endpoints, 'client-uuid'); +}); + +afterEach(function () { + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); +}); + +it('runs the full PKCE flow and returns a TokenRecord', function () { + Http::fake([ + 'https://passport-oauth.test/oauth/token' => Http::response([ + 'access_token' => 'pkce-access', + 'refresh_token' => 'pkce-refresh', + 'expires_in' => 1296000, + ]), + ]); + + $server = new LocalCallbackServer; + $sentUrl = null; + + $browser = function (string $url) use ($server, &$sentUrl) { + $sentUrl = $url; + parse_str((string) parse_url($url, PHP_URL_QUERY), $params); + + $client = stream_socket_client("tcp://127.0.0.1:{$server->port}"); + fwrite( + $client, + "GET /callback?code=fake-code&state={$params['state']} HTTP/1.1\r\n" + ."Host: 127.0.0.1\r\n\r\n", + ); + fclose($client); + }; + + $flow = new PkceLoginFlow($this->httpClient, $this->endpoints, 'client-uuid', ['read', 'write']); + + $record = $flow->run($browser, fn () => null, server: $server, timeoutSeconds: 5); + + expect($record->accessToken)->toBe('pkce-access'); + expect($record->scopes)->toBe(['read', 'write']); + + expect($sentUrl)->toStartWith('https://passport-oauth.test/oauth/authorize?'); + expect($sentUrl)->toContain('response_type=code'); + expect($sentUrl)->toContain('code_challenge_method=S256'); + expect($sentUrl)->toContain('scope=read%20write'); + expect($sentUrl)->toContain('client_id=client-uuid'); + + Http::assertSent(fn ($request) => $request->url() === 'https://passport-oauth.test/oauth/token' + && $request['code'] === 'fake-code' + && $request['grant_type'] === 'authorization_code'); +}); + +it('aborts and never exchanges when the callback state does not match', function () { + Http::fake(); + + $server = new LocalCallbackServer; + + $browser = function () use ($server) { + $client = stream_socket_client("tcp://127.0.0.1:{$server->port}"); + fwrite( + $client, + "GET /callback?code=fake-code&state=WRONG-STATE HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + fclose($client); + }; + + $flow = new PkceLoginFlow($this->httpClient, $this->endpoints, 'client-uuid', ['read']); + + expect(fn () => $flow->run($browser, fn () => null, server: $server, timeoutSeconds: 5)) + ->toThrow(OAuthException::class, 'state did not match'); + + Http::assertNothingSent(); +}); + +it('surfaces OAuth provider errors returned via the callback', function () { + Http::fake(); + + $server = new LocalCallbackServer; + + $browser = function () use ($server) { + $client = stream_socket_client("tcp://127.0.0.1:{$server->port}"); + fwrite( + $client, + "GET /callback?error=access_denied&error_description=user+said+no HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ); + fclose($client); + }; + + $flow = new PkceLoginFlow($this->httpClient, $this->endpoints, 'client-uuid', ['read']); + + expect(fn () => $flow->run($browser, fn () => null, server: $server, timeoutSeconds: 5)) + ->toThrow(OAuthException::class, 'access_denied'); + + Http::assertNothingSent(); +}); diff --git a/tests/Unit/OAuthEndpointsTest.php b/tests/Unit/OAuthEndpointsTest.php new file mode 100644 index 0000000..7d4cb49 --- /dev/null +++ b/tests/Unit/OAuthEndpointsTest.php @@ -0,0 +1,40 @@ +originalBaseUrl = getenv('FLARE_BASE_URL') ?: null; + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); +}); + +afterEach(function () { + if ($this->originalBaseUrl === null) { + putenv('FLARE_BASE_URL'); + unset($_SERVER['FLARE_BASE_URL']); + } else { + putenv("FLARE_BASE_URL={$this->originalBaseUrl}"); + $_SERVER['FLARE_BASE_URL'] = $this->originalBaseUrl; + } +}); + +it('builds production endpoints by default', function () { + $endpoints = new OAuthEndpoints(new FlareUrlResolver); + + expect($endpoints->authorize())->toBe('https://flareapp.io/oauth/authorize'); + expect($endpoints->token())->toBe('https://flareapp.io/oauth/token'); + expect($endpoints->deviceCode())->toBe('https://flareapp.io/oauth/device/code'); + expect($endpoints->deviceVerification())->toBe('https://flareapp.io/oauth/device'); +}); + +it('derives endpoints from FLARE_BASE_URL', function () { + putenv('FLARE_BASE_URL=https://passport-oauth.test/api'); + $_SERVER['FLARE_BASE_URL'] = 'https://passport-oauth.test/api'; + + $endpoints = new OAuthEndpoints(new FlareUrlResolver); + + expect($endpoints->authorize())->toBe('https://passport-oauth.test/oauth/authorize'); + expect($endpoints->token())->toBe('https://passport-oauth.test/oauth/token'); + expect($endpoints->deviceCode())->toBe('https://passport-oauth.test/oauth/device/code'); +}); diff --git a/tests/Unit/PkceCodesTest.php b/tests/Unit/PkceCodesTest.php new file mode 100644 index 0000000..ac686eb --- /dev/null +++ b/tests/Unit/PkceCodesTest.php @@ -0,0 +1,44 @@ +toBe($expectedChallenge); +}); + +it('generates verifiers within the RFC 7636 length range', function () { + foreach (range(1, 5) as $_) { + $verifier = PkceCodes::verifier(); + + expect(strlen($verifier))->toBeGreaterThanOrEqual(43); + expect(strlen($verifier))->toBeLessThanOrEqual(128); + expect($verifier)->toMatch('/^[A-Za-z0-9\-._~]+$/'); + } +}); + +it('generates unique verifiers across calls', function () { + $a = PkceCodes::verifier(); + $b = PkceCodes::verifier(); + + expect($a)->not->toBe($b); +}); + +it('generates state values as hex strings', function () { + $state = PkceCodes::state(); + + expect($state)->toMatch('/^[0-9a-f]+$/'); + expect(strlen($state))->toBe(32); +}); + +it('outputs base64url challenges with no padding or unsafe chars', function () { + $challenge = PkceCodes::challenge('a-test-verifier-of-sufficient-length-1234567'); + + expect($challenge)->toMatch('/^[A-Za-z0-9\-_]+$/'); + expect($challenge)->not->toContain('='); + expect($challenge)->not->toContain('+'); + expect($challenge)->not->toContain('/'); +}); diff --git a/tests/Unit/TokenRecordTest.php b/tests/Unit/TokenRecordTest.php new file mode 100644 index 0000000..bd8627c --- /dev/null +++ b/tests/Unit/TokenRecordTest.php @@ -0,0 +1,82 @@ + 'oauth', + 'access_token' => 'access-abc', + 'refresh_token' => 'refresh-xyz', + 'expires_at' => 1_700_000_000, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => 1_699_999_000, + ]; + + expect(TokenRecord::fromArray($data)->toArray())->toBe($data); +}); + +it('coerces non-string scope values away', function () { + $record = TokenRecord::fromArray([ + 'access_token' => 'a', + 'refresh_token' => 'r', + 'expires_at' => 100, + 'scopes' => ['read', 42, null, 'write'], + 'client_id' => 'c', + 'obtained_at' => 50, + ]); + + expect($record->scopes)->toBe(['read', 'write']); +}); + +it('throws when required fields are missing', function () { + TokenRecord::fromArray(['access_token' => 'a']); +})->throws(InvalidArgumentException::class); + +it('detects expiry within a threshold', function () { + $now = 1_000_000; + $record = TokenRecord::fromArray([ + 'access_token' => 'a', + 'refresh_token' => 'r', + 'expires_at' => $now + 30, + 'scopes' => [], + 'client_id' => 'c', + 'obtained_at' => $now, + ]); + + expect($record->isExpiringWithin(60, $now))->toBeTrue(); + expect($record->isExpiringWithin(10, $now))->toBeFalse(); +}); + +it('preserves immutable fields across refresh', function () { + $original = TokenRecord::fromArray([ + 'access_token' => 'old-access', + 'refresh_token' => 'old-refresh', + 'expires_at' => 100, + 'scopes' => ['read'], + 'client_id' => 'client-id', + 'obtained_at' => 50, + ]); + + $refreshed = $original->withRefreshed( + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresAt: 200, + obtainedAt: 150, + ); + + expect($refreshed->accessToken)->toBe('new-access'); + expect($refreshed->refreshToken)->toBe('new-refresh'); + expect($refreshed->expiresAt)->toBe(200); + expect($refreshed->obtainedAt)->toBe(150); + expect($refreshed->scopes)->toBe(['read']); + expect($refreshed->clientId)->toBe('client-id'); +}); + +it('identifies OAuth-shaped arrays', function () { + expect(TokenRecord::looksLikeRecord(['type' => 'oauth']))->toBeTrue(); + expect(TokenRecord::looksLikeRecord(['type' => 'other']))->toBeFalse(); + expect(TokenRecord::looksLikeRecord('plain-string'))->toBeFalse(); + expect(TokenRecord::looksLikeRecord(null))->toBeFalse(); + expect(TokenRecord::looksLikeRecord([]))->toBeFalse(); +}); diff --git a/tests/Unit/TokenRefresherTest.php b/tests/Unit/TokenRefresherTest.php new file mode 100644 index 0000000..449c309 --- /dev/null +++ b/tests/Unit/TokenRefresherTest.php @@ -0,0 +1,59 @@ + 'old-access', + 'refresh_token' => 'old-refresh', + 'expires_at' => $expiresAt, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => $obtainedAt, + ]); +} + +it('returns the same record when expiry is outside the threshold', function () { + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldNotReceive('refresh'); + + $now = 1_000_000; + $record = refresherRecord(expiresAt: $now + 3600); + + $refresher = new TokenRefresher($client, thresholdSeconds: 60); + + expect($refresher->refreshIfNeeded($record, $now))->toBe($record); +}); + +it('refreshes when expiry is within the threshold', function () { + $now = 1_000_000; + $stale = refresherRecord(expiresAt: $now + 30); + $fresh = TokenRecord::fromArray([ + 'access_token' => 'new-access', + 'refresh_token' => 'new-refresh', + 'expires_at' => $now + 1296000, + 'scopes' => ['read', 'write'], + 'client_id' => 'client-uuid', + 'obtained_at' => $now, + ]); + + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('refresh')->once()->with($stale)->andReturn($fresh); + + $refresher = new TokenRefresher($client, thresholdSeconds: 60); + + expect($refresher->refreshIfNeeded($stale, $now))->toBe($fresh); +}); + +it('refreshes unconditionally via refresh()', function () { + $original = refresherRecord(expiresAt: time() + 99999); + $fresh = refresherRecord(expiresAt: time() + 99999); + + $client = Mockery::mock(OAuthHttpClient::class); + $client->shouldReceive('refresh')->once()->andReturn($fresh); + + expect((new TokenRefresher($client))->refresh($original))->toBe($fresh); +}); From 2c91341f7b869c4592a39123eb98263e00c55dde Mon Sep 17 00:00:00 2001 From: Alex Vanderbist Date: Fri, 22 May 2026 17:20:08 +0200 Subject: [PATCH 2/3] Document the new OAuth login flows in the Flare CLI skill --- skills/flare/SKILL.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/skills/flare/SKILL.md b/skills/flare/SKILL.md index e8a7d1a..184836c 100644 --- a/skills/flare/SKILL.md +++ b/skills/flare/SKILL.md @@ -10,7 +10,7 @@ description: >- license: MIT metadata: author: spatie - version: "0.0.1" + version: "0.1.0" --- # Flare CLI @@ -39,17 +39,32 @@ composer global config bin-dir --absolute ## Authentication +`flare login` is interactive. **Recommend the user run it themselves** โ€” don't try to drive it from inside an agent session. + ```bash -# Log in โ€” you'll be prompted for your API token +# Default: browser-based OAuth (PKCE). Opens a browser to flareapp.io, +# user approves the requested scopes + team/project access, tokens are +# stored locally and refreshed transparently before each API call. flare login +# Headless / SSH terminal: device-code flow. Prints a short user code + +# verification URL; the user enters the code on any other device. +flare login --device + +# Escape hatch: paste a personal access token (or legacy API token) +# instead of going through the browser flow. +flare login --token + # Log out -flare logout +flare logout # only the active host +flare logout --all # every configured host ``` -Get your API token at https://flareapp.io/settings/api-tokens. +Tokens are stored per-host in `~/.flare/config.json`. The active host comes from `FLARE_BASE_URL` (defaults to `https://flareapp.io/api`). + +Personal access tokens can still be generated at https://flareapp.io/settings/api-tokens โ€” use them with `flare login --token` for scripts, CI, or any non-interactive context. -If any command returns a 401 error, the token is invalid or expired. Run `flare login` again. +If any command returns a 401 error, the credentials are invalid or expired. Suggest the user run `flare login` again. ## Quick command reference From 59e0ed72cec898380deecac31f177673f0b27d09 Mon Sep 17 00:00:00 2001 From: Alex Vanderbist Date: Sat, 18 Jul 2026 10:36:48 +0200 Subject: [PATCH 3/3] Align with the merged Flare OAuth server work - Clarify that the OAuth client id is the canonical seeded production value - Show a friendly re-login hint on 403 scope/grant denials - Update CLAUDE.md now that the spec is fetched from the live URL --- CLAUDE.md | 9 ++--- app/Providers/AppServiceProvider.php | 17 +++++++++ config/flare.php | 7 ++-- tests/Feature/ForbiddenCommandTest.php | 48 ++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/ForbiddenCommandTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 1186078..59136ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,20 +11,19 @@ A standalone CLI tool for [Flare](https://flareapp.io) built on Laravel Zero. Us ## Architecture - **Laravel Zero 12** โ€” PHP CLI micro-framework -- **spatie/laravel-openapi-cli** โ€” reads `resources/openapi/flare-api.yaml` and registers one command per API endpoint using `operationId`-based naming with `flare:` prefix +- **spatie/laravel-openapi-cli** โ€” fetches the spec from `https://flareapp.io/downloads/flare-api.yaml` (cached for 24h) and registers one command per API endpoint using `operationId`-based naming with `flare:` prefix - **CredentialStore** (`app/Services/CredentialStore.php`) โ€” reads/writes API token to `~/.flare/config.json` - **LoginCommand / LogoutCommand** โ€” custom commands (not from OpenAPI spec) for auth flow ## Key files - `flare` โ€” CLI entry point (the binary) -- `app/Providers/AppServiceProvider.php` โ€” registers CredentialStore singleton and OpenApiCli +- `app/Providers/AppServiceProvider.php` โ€” registers CredentialStore singleton and OpenApiCli (spec URL, auth, error messaging) - `app/Services/CredentialStore.php` โ€” credential persistence to `~/.flare/config.json` - `app/Commands/LoginCommand.php` โ€” `flare login` - `app/Commands/LogoutCommand.php` โ€” `flare logout` -- `resources/openapi/flare-api.yaml` โ€” bundled Flare API spec - `config/app.php` โ€” providers list (must manually register `OpenApiCliServiceProvider`) -- `box.json` โ€” PHAR build config (must include `resources` directory) +- `box.json` โ€” PHAR build config ## Development setup @@ -38,8 +37,6 @@ When updating the AI agent skill (e.g. filters, sorts, available commands), alwa - Laravel Zero disables package auto-discovery. Any package service providers must be registered manually in `config/app.php`. - The `.auth()` callable (not `.bearer()`) is used for dynamic credential resolution from `CredentialStore`. -- The `resources/` directory must be in `box.json` `directories` for the spec to be bundled in the PHAR. -- `resource_path()` resolves to `phar://` paths when running inside a PHAR โ€” this works for reading but not writing. ## Coding standards diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index be2275e..2662418 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -47,6 +47,23 @@ public function boot(): void return true; } + if ($response->status() === 403) { + $message = $response->json('message'); + + // Token-grant denials from Flare's ApiAccessAuthorizer all start + // with "Token " ("Token is missing the 'write' scope.", "Token + // does not grant access to this team.", ...). Other 403s are + // genuine permission errors that a re-login won't fix. + if (is_string($message) && str_starts_with($message, 'Token ')) { + $command->error($message); + $command->line( + 'Run `flare login` to re-authenticate and adjust the scopes, teams, and projects granted to the CLI.', + ); + + return true; + } + } + return false; }); } diff --git a/config/flare.php b/config/flare.php index a86aeb2..175a0db 100644 --- a/config/flare.php +++ b/config/flare.php @@ -7,10 +7,11 @@ |-------------------------------------------------------------------------- | | Flare's first-party CLI client is a public Passport client (no secret). - | The client_id below is the development seed value. Replace with the - | production UUID before tagging a release. + | The client_id below is the canonical "Flare CLI" client, seeded in + | production by flareapp.io's seed_first_party_oauth_clients migration. | - | Override per-environment with the FLARE_OAUTH_CLIENT_ID env var. + | Override with the FLARE_OAUTH_CLIENT_ID env var when developing + | against a local Flare server with different client ids. */ 'oauth' => [ diff --git a/tests/Feature/ForbiddenCommandTest.php b/tests/Feature/ForbiddenCommandTest.php new file mode 100644 index 0000000..fbb2760 --- /dev/null +++ b/tests/Feature/ForbiddenCommandTest.php @@ -0,0 +1,48 @@ +tempDir = sys_get_temp_dir().'/flare-cli-test-'.uniqid(); + mkdir($this->tempDir, 0755, true); + $_SERVER['HOME'] = $this->tempDir; + + $this->store = new CredentialStore; + $this->store->setToken('test-api-token-123'); + $this->app->instance(CredentialStore::class, $this->store); +}); + +afterEach(function () { + $configFile = $this->tempDir.'/.flare/config.json'; + if (file_exists($configFile)) { + unlink($configFile); + } + if (is_dir($this->tempDir.'/.flare')) { + rmdir($this->tempDir.'/.flare'); + } + if (is_dir($this->tempDir)) { + rmdir($this->tempDir); + } +}); + +it('suggests re-login when the token is missing a scope or grant', function () { + Http::fake([ + 'flareapp.io/api/*' => Http::response(['message' => "Token is missing the 'write' scope."], 403), + ]); + + $this->artisan('list-projects') + ->expectsOutputToContain("Token is missing the 'write' scope.") + ->expectsOutputToContain('Run `flare login` to re-authenticate') + ->assertExitCode(1); +}); + +it('does not suggest re-login for other permission errors', function () { + Http::fake([ + 'flareapp.io/api/*' => Http::response(['message' => 'This action is unauthorized.'], 403), + ]); + + $this->artisan('list-projects') + ->doesntExpectOutputToContain('Run `flare login` to re-authenticate') + ->assertExitCode(1); +});