From eb66cf522199818685832fd1e5667d6f2c65170d Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Tue, 13 Jan 2026 14:25:00 +0100 Subject: [PATCH 1/2] Add device auth login to share commands Implement automatic OAuth login flow when authentication token is missing. Users running share commands without authentication are now prompted to login via their browser, supporting GitHub and Google OAuth as well as email/password authentication. --- app/Commands/Concerns/TriggersLogin.php | 187 ++++++++++++++++++++++++ app/Commands/LoginCommand.php | 30 ++++ app/Commands/ShareCommand.php | 11 +- app/Commands/SharePortCommand.php | 21 +++ 4 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 app/Commands/Concerns/TriggersLogin.php create mode 100644 app/Commands/LoginCommand.php diff --git a/app/Commands/Concerns/TriggersLogin.php b/app/Commands/Concerns/TriggersLogin.php new file mode 100644 index 00000000..a9053e0e --- /dev/null +++ b/app/Commands/Concerns/TriggersLogin.php @@ -0,0 +1,187 @@ +expose.dev."); + info(); + info("Opening your browser to login or create an account..."); + + try { + $response = Http::withHeaders([ + 'Accept' => 'application/json', + ])->post($apiEndpoint . 'create'); + + if (! $response->ok()) { + error('Failed to connect to the Expose platform. Please try again.'); + return false; + } + + $data = $response->json(); + $deviceCode = $data['device_code'] ?? null; + + if (! $deviceCode) { + error('Failed to connect to the Expose platform. Please try again.'); + return false; + } + } catch (\Exception $e) { + error('Failed to connect to the Expose platform. Please check your internet connection.'); + return false; + } + + // Open browser + $loginUrl = rtrim($platformUrl, '/') . '/cli/login?device_code=' . $deviceCode; + + $this->openBrowser($loginUrl); + + info(); + info("If the browser doesn't open automatically, visit:"); + info("$loginUrl"); + info(); + + // Poll for completion + $token = $this->pollForAuthentication($apiEndpoint, $deviceCode); + + if (! $token) { + error('Authentication timed out or was cancelled. Please try again.'); + return false; + } + + // Store the token + $this->storeToken($token); + + info(); + success("You're all set! Your Expose account has been connected."); + info(); + + // Run pro setup if applicable + (new SetupExposeProToken)($token); + + return true; + } + + protected function pollForAuthentication(string $apiEndpoint, string $deviceCode): ?string + { + $maxAttempts = 150; // 5 minutes at 2 second intervals + $attempt = 0; + + return spin( + callback: function () use ($apiEndpoint, $deviceCode, $maxAttempts, &$attempt) { + while ($attempt < $maxAttempts) { + $attempt++; + + try { + $response = Http::withHeaders([ + 'Accept' => 'application/json', + ])->post($apiEndpoint . 'status', [ + 'device_code' => $deviceCode, + ]); + + if ($response->ok()) { + $data = $response->json(); + $status = $data['status'] ?? 'pending'; + + if ($status === 'completed') { + return $data['token'] ?? null; + } + + if ($status === 'expired') { + return null; + } + } + } catch (\Exception $e) { + // Continue polling + } + + sleep(2); + } + + return null; + }, + message: 'Waiting for authentication...' + ); + } + + protected function openBrowser(string $url): void + { + $command = match (PHP_OS_FAMILY) { + 'Darwin' => 'open', + 'Windows' => 'start', + default => 'xdg-open', + }; + + exec("$command " . escapeshellarg($url) . " > /dev/null 2>&1 &"); + } + + protected function storeToken(string $token): void + { + $configFile = implode(DIRECTORY_SEPARATOR, [ + $_SERVER['HOME'] ?? $_SERVER['USERPROFILE'], + '.expose', + 'config.php', + ]); + + if (! file_exists($configFile)) { + @mkdir(dirname($configFile), 0777, true); + $updatedConfigFile = $this->modifyConfigurationFileForToken(base_path('config/expose.php'), $token); + } else { + $updatedConfigFile = $this->modifyConfigurationFileForToken($configFile, $token); + } + + file_put_contents($configFile, $updatedConfigFile); + + // Update the runtime config + config(['expose.auth_token' => $token]); + } + + protected function modifyConfigurationFileForToken(string $configFile, string $token): string + { + $lexer = new Emulative([ + 'usedAttributes' => [ + 'comments', + 'startLine', + 'endLine', + 'startTokenPos', + 'endTokenPos', + ], + ]); + $parser = new Php7($lexer); + + $oldStmts = $parser->parse(file_get_contents($configFile)); + $oldTokens = $lexer->getTokens(); + + $nodeTraverser = new NodeTraverser; + $nodeTraverser->addVisitor(new CloningVisitor()); + $newStmts = $nodeTraverser->traverse($oldStmts); + + $nodeTraverser = new NodeTraverser; + $nodeTraverser->addVisitor(new TokenNodeVisitor($token)); + + $newStmts = $nodeTraverser->traverse($newStmts); + + $prettyPrinter = new Standard(); + + return $prettyPrinter->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + } +} diff --git a/app/Commands/LoginCommand.php b/app/Commands/LoginCommand.php new file mode 100644 index 00000000..b554a497 --- /dev/null +++ b/app/Commands/LoginCommand.php @@ -0,0 +1,30 @@ +clear(); + banner(); + + if (! $this->triggerLogin()) { + return 1; + } + + return 0; + } +} diff --git a/app/Commands/ShareCommand.php b/app/Commands/ShareCommand.php index d3f0a7a8..c21b2548 100644 --- a/app/Commands/ShareCommand.php +++ b/app/Commands/ShareCommand.php @@ -5,6 +5,7 @@ use Expose\Client\Commands\Concerns\DetectsLocalDevelopmentSites; use Expose\Client\Commands\Concerns\SharesViteServer; +use Expose\Client\Commands\Concerns\TriggersLogin; use Expose\Client\Factory; use chillerlan\QRCode\Common\Version; use chillerlan\QRCode\Data\QRMatrix; @@ -25,6 +26,7 @@ class ShareCommand extends ServerAwareCommand { use DetectsLocalDevelopmentSites; use SharesViteServer; + use TriggersLogin; protected $signature = 'share {host} {--subdomain=} {--auth=} {--basicAuth=} {--dns=} {--domain=} {--prevent-cors} {--no-vite-detection} {--qr} {--qr-code}'; @@ -172,12 +174,9 @@ protected function ensureEnvironmentSetup(): void protected function ensureExposeSetup(): void { if (empty(config('expose.auth_token'))) { - info(); - error('No authentication token set.'); - info(); - - info("If you don't have an Expose account yet, you can start for free at expose.dev."); - exit; + if (! $this->triggerLogin()) { + exit(1); + } } } diff --git a/app/Commands/SharePortCommand.php b/app/Commands/SharePortCommand.php index 727a11a8..7ac15e93 100644 --- a/app/Commands/SharePortCommand.php +++ b/app/Commands/SharePortCommand.php @@ -2,17 +2,29 @@ namespace Expose\Client\Commands; +use Expose\Client\Commands\Concerns\TriggersLogin; use Expose\Client\Factory; use React\EventLoop\LoopInterface; +use function Expose\Common\banner; +use function Expose\Common\info; +use function Termwind\terminal; + class SharePortCommand extends ServerAwareCommand { + use TriggersLogin; + protected $signature = 'share-port {port} {--auth=}'; protected $description = 'Share a local port with a remote expose server'; public function handle() { + terminal()->clear(); + banner(); + + $this->ensureExposeSetup(); + $auth = $this->option('auth') ?? config('expose.auth_token', ''); (new Factory()) @@ -25,4 +37,13 @@ public function handle() ->createHttpServer() ->run(); } + + protected function ensureExposeSetup(): void + { + if (empty(config('expose.auth_token'))) { + if (! $this->triggerLogin()) { + exit(1); + } + } + } } From b0c60aba690bda2528633cd369a879a14351684f Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Wed, 14 Jan 2026 10:16:37 +0100 Subject: [PATCH 2/2] Consolidate ensureExposeSetup into TriggersLogin trait --- app/Commands/Concerns/TriggersLogin.php | 10 +++++++++- app/Commands/ShareCommand.php | 10 ---------- app/Commands/SharePortCommand.php | 10 ---------- 3 files changed, 9 insertions(+), 21 deletions(-) diff --git a/app/Commands/Concerns/TriggersLogin.php b/app/Commands/Concerns/TriggersLogin.php index a9053e0e..0143d3d0 100644 --- a/app/Commands/Concerns/TriggersLogin.php +++ b/app/Commands/Concerns/TriggersLogin.php @@ -5,7 +5,6 @@ use Expose\Client\Commands\SetupExposeProToken; use Expose\Client\Support\TokenNodeVisitor; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Str; use PhpParser\Lexer\Emulative; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\CloningVisitor; @@ -184,4 +183,13 @@ protected function modifyConfigurationFileForToken(string $configFile, string $t return $prettyPrinter->printFormatPreserving($newStmts, $oldStmts, $oldTokens); } + + protected function ensureExposeSetup(): void + { + if (empty(config('expose.auth_token'))) { + if (! $this->triggerLogin()) { + exit(1); + } + } + } } diff --git a/app/Commands/ShareCommand.php b/app/Commands/ShareCommand.php index c21b2548..f062999f 100644 --- a/app/Commands/ShareCommand.php +++ b/app/Commands/ShareCommand.php @@ -2,7 +2,6 @@ namespace Expose\Client\Commands; - use Expose\Client\Commands\Concerns\DetectsLocalDevelopmentSites; use Expose\Client\Commands\Concerns\SharesViteServer; use Expose\Client\Commands\Concerns\TriggersLogin; @@ -171,15 +170,6 @@ protected function ensureEnvironmentSetup(): void } } - protected function ensureExposeSetup(): void - { - if (empty(config('expose.auth_token'))) { - if (! $this->triggerLogin()) { - exit(1); - } - } - } - protected function isWmicAvailable(): bool { $output = []; diff --git a/app/Commands/SharePortCommand.php b/app/Commands/SharePortCommand.php index 7ac15e93..f43202ae 100644 --- a/app/Commands/SharePortCommand.php +++ b/app/Commands/SharePortCommand.php @@ -7,7 +7,6 @@ use React\EventLoop\LoopInterface; use function Expose\Common\banner; -use function Expose\Common\info; use function Termwind\terminal; class SharePortCommand extends ServerAwareCommand @@ -37,13 +36,4 @@ public function handle() ->createHttpServer() ->run(); } - - protected function ensureExposeSetup(): void - { - if (empty(config('expose.auth_token'))) { - if (! $this->triggerLogin()) { - exit(1); - } - } - } }