From 5eb0dcf2174d550899f99055df8440e0a9dca1e2 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Fri, 23 Jan 2026 16:10:13 +0100 Subject: [PATCH 1/3] Add magic login link authentication feature Implement email-based access control for shared URLs as an alternative to basic auth: - Add --magic-auth CLI flag with optional patterns (domains/emails) for access restriction - Create CheckMagicAuthentication modifier with cookie-based auth and login form - Display Magic Auth status in connection table when enabled - Log magic login events visible in CLI and dashboard - Separate login form into Blade view with light theme matching dashboard design Co-Authored-By: Claude Haiku 4.5 --- app/Client.php | 11 +- app/Commands/ShareCommand.php | 17 +- app/Configuration.php | 27 ++- app/Factory.php | 12 +- app/Http/HttpClient.php | 3 + .../Modifiers/CheckMagicAuthentication.php | 226 ++++++++++++++++++ app/Logger/Plugins/MagicLoginPlugin.php | 40 ++++ app/Logger/Plugins/PluginManager.php | 11 + app/Logger/RequestLogger.php | 14 +- config/expose.php | 3 +- resources/views/client/magic_login.blade.php | 194 +++++++++++++++ 11 files changed, 549 insertions(+), 9 deletions(-) create mode 100644 app/Http/Modifiers/CheckMagicAuthentication.php create mode 100644 app/Logger/Plugins/MagicLoginPlugin.php create mode 100644 resources/views/client/magic_login.blade.php diff --git a/app/Client.php b/app/Client.php index 36118336..85eda926 100644 --- a/app/Client.php +++ b/app/Client.php @@ -147,11 +147,18 @@ public function connectToServer(string $sharedUrl, $subdomain, $serverHost = nul $this->closingMessage = $data->closing_message ?? null; - $this->logger->renderConnectionTable([ + $connectionInfo = [ "Shared site" => $sharedUrl, "Dashboard" => "http://127.0.0.1:".config()->get('expose.dashboard_port'), "Public URL" => "https://{$data->subdomain}.{$host}", - ]); + ]; + + if ($this->configuration->magicAuth() !== null) { + $patterns = $this->configuration->getAllowedMagicAuthPatterns(); + $connectionInfo["Magic Auth"] = empty($patterns) ? "Enabled (any email)" : "Enabled (" . implode(', ', $patterns) . ")"; + } + + $this->logger->renderConnectionTable($connectionInfo); $this->logger->line(''); static::$subdomains[] = "{$httpProtocol}://{$data->subdomain}.{$host}"; diff --git a/app/Commands/ShareCommand.php b/app/Commands/ShareCommand.php index f062999f..c3f3a9a3 100644 --- a/app/Commands/ShareCommand.php +++ b/app/Commands/ShareCommand.php @@ -27,7 +27,7 @@ class ShareCommand extends ServerAwareCommand use SharesViteServer; use TriggersLogin; - protected $signature = 'share {host} {--subdomain=} {--auth=} {--basicAuth=} {--dns=} {--domain=} {--prevent-cors} {--no-vite-detection} {--qr} {--qr-code}'; + protected $signature = 'share {host} {--subdomain=} {--auth=} {--basicAuth=} {--magic-auth=} {--dns=} {--domain=} {--prevent-cors} {--no-vite-detection} {--qr} {--qr-code}'; protected $description = 'Share a local url with a remote expose server'; @@ -50,6 +50,11 @@ public function handle() info("Using basic auth: ". $this->option('basicAuth'), options: OutputInterface::VERBOSITY_VERBOSE); + if ($this->getMagicAuthValue() !== null) { + $magicAuthPatterns = $this->getMagicAuthValue() ?: 'any email'; + info("Using magic auth: ". $magicAuthPatterns, options: OutputInterface::VERBOSITY_VERBOSE); + } + if (strstr($this->argument('host'), 'host.docker.internal')) { config(['expose.dns' => true]); } @@ -108,6 +113,7 @@ public function handle() ->setPort($this->getServerPort()) ->setAuth($auth) ->setBasicAuth($this->option('basicAuth')) + ->setMagicAuth($this->getMagicAuthValue()) ->setPreventCORS($this->option('prevent-cors')) ->createClient() ->share( @@ -188,4 +194,13 @@ protected function isWindows(): bool return $this->isWindows; } + + protected function getMagicAuthValue(): ?string + { + if (!$this->input->hasParameterOption('--magic-auth')) { + return null; + } + + return $this->option('magic-auth') ?? ''; + } } diff --git a/app/Configuration.php b/app/Configuration.php index 10249fca..79759ce5 100644 --- a/app/Configuration.php +++ b/app/Configuration.php @@ -2,6 +2,8 @@ namespace Expose\Client; +use Illuminate\Support\Str; + class Configuration { /** @var string */ @@ -19,13 +21,16 @@ class Configuration /** @var string|null */ protected $basicAuth; + /** @var string|null */ + protected $magicAuth; + /** @var bool */ protected $isSecureSharedUrl = false; /** @var bool */ protected $preventCORS = false; - public function __construct(string $host, int $port, ?string $auth = null, ?string $basicAuth = null, bool $preventCORS = false) + public function __construct(string $host, int $port, ?string $auth = null, ?string $basicAuth = null, bool $preventCORS = false, ?string $magicAuth = null) { $this->serverHost = $this->host = $host; @@ -35,7 +40,13 @@ public function __construct(string $host, int $port, ?string $auth = null, ?stri $this->basicAuth = $basicAuth; + $this->magicAuth = $magicAuth; + $this->preventCORS = $preventCORS; + + if ($this->magicAuth !== null) { + config(['expose.magic-auth-secret-key' => Str::random(32)]); + } } public function host(): string @@ -63,6 +74,20 @@ public function basicAuth(): ?string return $this->basicAuth; } + public function magicAuth(): ?string + { + return $this->magicAuth; + } + + public function getAllowedMagicAuthPatterns(): array + { + if (is_null($this->magicAuth) || $this->magicAuth === '') { + return []; + } + + return array_filter(array_map('trim', explode(',', $this->magicAuth))); + } + public function preventCORS(): bool { return $this->preventCORS; diff --git a/app/Factory.php b/app/Factory.php index 71ac8940..fdbd1a33 100644 --- a/app/Factory.php +++ b/app/Factory.php @@ -40,6 +40,9 @@ class Factory /** @var string */ protected $basicAuth; + /** @var string|null */ + protected $magicAuth; + /** @var bool */ protected $preventCORS = false; @@ -86,6 +89,13 @@ public function setBasicAuth(?string $basicAuth) return $this; } + public function setMagicAuth(?string $magicAuth) + { + $this->magicAuth = $magicAuth; + + return $this; + } + public function setPreventCORS(bool $preventCORS) { $this->preventCORS = $preventCORS; @@ -103,7 +113,7 @@ public function setLoop(LoopInterface $loop) protected function bindConfiguration() { app()->singleton(Configuration::class, function ($app) { - return new Configuration($this->host, $this->port, $this->auth, $this->basicAuth, $this->preventCORS); + return new Configuration($this->host, $this->port, $this->auth, $this->basicAuth, $this->preventCORS, $this->magicAuth); }); } diff --git a/app/Http/HttpClient.php b/app/Http/HttpClient.php index 6c4c18f5..fb32855a 100644 --- a/app/Http/HttpClient.php +++ b/app/Http/HttpClient.php @@ -4,6 +4,7 @@ use Expose\Client\Configuration; use Expose\Client\Http\Modifiers\CheckBasicAuthentication; +use Expose\Client\Http\Modifiers\CheckMagicAuthentication; use Expose\Client\Logger\RequestLogger; use GuzzleHttp\Psr7\Message; use Laminas\Http\Request; @@ -32,7 +33,9 @@ class HttpClient /** @var array */ protected $modifiers = [ CheckBasicAuthentication::class, + CheckMagicAuthentication::class, ]; + /** @var Configuration */ protected $configuration; diff --git a/app/Http/Modifiers/CheckMagicAuthentication.php b/app/Http/Modifiers/CheckMagicAuthentication.php new file mode 100644 index 00000000..7fe0f5e6 --- /dev/null +++ b/app/Http/Modifiers/CheckMagicAuthentication.php @@ -0,0 +1,226 @@ +requiresAuthentication() || is_null($proxyConnection)) { + return $request; + } + + if ($this->isLoginFormSubmission($request)) { + return $this->handleLoginFormSubmission($request, $proxyConnection); + } + + if ($this->hasValidAuthCookie($request)) { + return $request; + } + + return $this->showLoginForm($request, $proxyConnection); + } + + protected function requiresAuthentication(): bool + { + return $this->configuration->magicAuth() !== null; + } + + protected function isLoginFormSubmission(RequestInterface $request): bool + { + $uri = $request->getUri()->getPath(); + $method = strtoupper($request->getMethod()); + + return $uri === self::LOGIN_PATH && $method === 'POST'; + } + + protected function handleLoginFormSubmission(RequestInterface $request, WebSocket $proxyConnection): ?RequestInterface + { + $body = (string) $request->getBody(); + parse_str($body, $formData); + + $email = trim($formData['email'] ?? ''); + $redirectUrl = $formData['redirect_url'] ?? '/'; + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return $this->showLoginForm($request, $proxyConnection, 'Please enter a valid email address.', $redirectUrl); + } + + if (!$this->isEmailAllowed($email)) { + return $this->showLoginForm($request, $proxyConnection, 'This email address is not authorized to access this site.', $redirectUrl); + } + + $cookieValue = $this->generateCookieValue($email); + + $response = new Response(302, [ + 'Location' => $redirectUrl, + 'Set-Cookie' => self::COOKIE_NAME . '=' . $cookieValue . '; Path=/; Max-Age=' . self::COOKIE_LIFETIME . '; HttpOnly; SameSite=Lax', + ]); + + $this->sendResponse($request, $proxyConnection, $response); + + return null; + } + + protected function isEmailAllowed(string $email): bool + { + $patterns = $this->configuration->getAllowedMagicAuthPatterns(); + + if (empty($patterns)) { + return true; + } + + $email = strtolower($email); + + foreach ($patterns as $pattern) { + $pattern = strtolower(trim($pattern)); + + if (str_starts_with($pattern, '@')) { + if (str_ends_with($email, $pattern)) { + return true; + } + } else { + if ($email === $pattern) { + return true; + } + } + } + + return false; + } + + protected function hasValidAuthCookie(RequestInterface $request): bool + { + $cookieValue = $this->getCookieValue($request, self::COOKIE_NAME); + + if (empty($cookieValue)) { + return false; + } + + return $this->validateCookieValue($cookieValue); + } + + protected function getCookieValue(RequestInterface $request, string $name): ?string + { + $cookieHeader = Arr::get($request->getHeaders(), 'cookie.0', ''); + + if (empty($cookieHeader)) { + return null; + } + + $cookies = []; + foreach (explode(';', $cookieHeader) as $cookie) { + $parts = explode('=', trim($cookie), 2); + if (count($parts) === 2) { + $cookies[trim($parts[0])] = trim($parts[1]); + } + } + + return $cookies[$name] ?? null; + } + + protected function generateCookieValue(string $email): string + { + $timestamp = time(); + $secret = $this->getSecret(); + $signature = hash_hmac('sha256', "{$email}|{$timestamp}", $secret); + + return base64_encode("{$email}|{$timestamp}|{$signature}"); + } + + protected function validateCookieValue(string $cookieValue): bool + { + $decoded = base64_decode($cookieValue); + + if ($decoded === false) { + return false; + } + + $parts = explode('|', $decoded); + + if (count($parts) !== 3) { + return false; + } + + [$email, $timestamp, $signature] = $parts; + + $secret = $this->getSecret(); + $expectedSignature = hash_hmac('sha256', "{$email}|{$timestamp}", $secret); + + if (!hash_equals($expectedSignature, $signature)) { + return false; + } + + if ((time() - (int) $timestamp) > self::COOKIE_LIFETIME) { + return false; + } + + return true; + } + + protected function getSecret(): string + { + return config('expose.magic-auth-secret-key'); + } + + protected function showLoginForm(RequestInterface $request, WebSocket $proxyConnection, ?string $error = null, ?string $redirectUrl = null): ?RequestInterface + { + $originalUrl = $redirectUrl ?? $request->getUri()->getPath(); + $query = $request->getUri()->getQuery(); + if ($query) { + $originalUrl .= '?' . $query; + } + + $html = view('client.magic_login', [ + 'error' => $error, + 'redirectUrl' => $originalUrl, + ])->render(); + + $response = new Response(401, [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'Content-Length' => strlen($html), + ], $html); + + $this->sendResponse($request, $proxyConnection, $response); + + return null; + } + + protected function sendResponse(RequestInterface $request, WebSocket $proxyConnection, Response $response): void + { + $rawResponse = Message::toString($response); + + if ($requestId = $this->getRequestId($request)) { + $this->requestLogger->logResponseById($requestId, $rawResponse); + } + + $proxyConnection->send($rawResponse); + $proxyConnection->close(); + } + + protected function getRequestId(RequestInterface $request): ?string + { + $headers = $request->getHeader('x-expose-request-id'); + return $headers[0] ?? null; + } +} diff --git a/app/Logger/Plugins/MagicLoginPlugin.php b/app/Logger/Plugins/MagicLoginPlugin.php new file mode 100644 index 00000000..26c218eb --- /dev/null +++ b/app/Logger/Plugins/MagicLoginPlugin.php @@ -0,0 +1,40 @@ +loggedRequest->getRequest(); + $uri = $request->getUriString(); + $method = $request->getMethod(); + + return str_contains($uri, '/__expose_magic_login') && strtoupper($method) === 'POST'; + } + + public function getPluginData(): PluginData + { + try { + $content = $this->loggedRequest->getRequest()->getContent(); + parse_str($content, $formData); + + $email = $formData['email'] ?? 'Unknown'; + + return PluginData::make() + ->setPlugin($this->getTitle()) + ->setLabel($email) + ->setDetails([ + 'email' => $email, + 'type' => 'magic_login', + ]); + } catch (\Throwable $e) { + return PluginData::error($this->getTitle(), $e); + } + } +} diff --git a/app/Logger/Plugins/PluginManager.php b/app/Logger/Plugins/PluginManager.php index d8b78eb6..223e4d9e 100644 --- a/app/Logger/Plugins/PluginManager.php +++ b/app/Logger/Plugins/PluginManager.php @@ -78,6 +78,17 @@ protected function ensureValidPluginConfig(): void $this->pluginConfig = array_diff($this->pluginConfig, [$pluginClass]); } } + + // Add forced plugins + $forcedPlugins = [ + MagicLoginPlugin::class, + ]; + + foreach ($forcedPlugins as $pluginClass) { + if (!in_array($pluginClass, $this->pluginConfig)) { + $this->pluginConfig[] = $pluginClass; + } + } } protected function loadCustomPlugins(): array diff --git a/app/Logger/RequestLogger.php b/app/Logger/RequestLogger.php index 36be48a9..acf137a9 100644 --- a/app/Logger/RequestLogger.php +++ b/app/Logger/RequestLogger.php @@ -36,15 +36,23 @@ public function logResponse(Request $request, string $rawResponse) return; } + $this->logResponseById($exposeRequestId, $rawResponse, $request); + } + + public function logResponseById(string $requestId, string $rawResponse, ?Request $request = null) + { try { - $loggedRequest = $this->logStorage->requests()->find($exposeRequestId); + $loggedRequest = $this->logStorage->requests()->find($requestId); - if($loggedRequest === null) { + if ($loggedRequest === null) { return; } $response = Response::fromString($rawResponse); - $loggedResponse = new LoggedResponse($rawResponse, $response, $request); + + // Use the logged request's parsed request if no request provided + $requestForLogging = $request ?? $loggedRequest->getRequest(); + $loggedResponse = new LoggedResponse($rawResponse, $response, $requestForLogging); $loggedRequest->setResponse($rawResponse, $response); $loggedRequest->setStopTime(); diff --git a/config/expose.php b/config/expose.php index 084381b8..fa78ec04 100644 --- a/config/expose.php +++ b/config/expose.php @@ -187,7 +187,8 @@ */ 'request_plugins' => [ \Expose\Client\Logger\Plugins\PaddleBillingPlugin::class, - \Expose\Client\Logger\Plugins\GitHubPlugin::class + \Expose\Client\Logger\Plugins\GitHubPlugin::class, + \Expose\Client\Logger\Plugins\MagicLoginPlugin::class, ] diff --git a/resources/views/client/magic_login.blade.php b/resources/views/client/magic_login.blade.php new file mode 100644 index 00000000..16a91db5 --- /dev/null +++ b/resources/views/client/magic_login.blade.php @@ -0,0 +1,194 @@ + + + + + + Access Required - Expose + + + + + + +
+
+ +
+
Expose
+
by Beyond Code
+
+
+ +

Access Required

+

Enter your email address to continue to this site.

+ + @if($error) +
{{ $error }}
+ @endif + +
+ + + + +
+ + +
+ + From c34907d5495743ca4e602e03461571c8008e669e Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Fri, 23 Jan 2026 16:17:56 +0100 Subject: [PATCH 2/3] add magic auth flag to cwd share --- app/Commands/ShareCurrentWorkingDirectoryCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Commands/ShareCurrentWorkingDirectoryCommand.php b/app/Commands/ShareCurrentWorkingDirectoryCommand.php index 4894ef28..ec58b823 100644 --- a/app/Commands/ShareCurrentWorkingDirectoryCommand.php +++ b/app/Commands/ShareCurrentWorkingDirectoryCommand.php @@ -15,7 +15,7 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand use SharesViteServer; use DetectsLocalDevelopmentSites; - protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=} {--basicAuth=} {--dns=} {--domain=} {--prevent-cors} {--no-vite-detection} {--qr} {--qr-code}'; + protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=} {--basicAuth=} {--magic-auth=} {--dns=} {--domain=} {--prevent-cors} {--no-vite-detection} {--qr} {--qr-code}'; public function handle() { From c44ebb5b769f697de676c5cb91f0c2d2dd1ccaef Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Fri, 23 Jan 2026 16:22:05 +0100 Subject: [PATCH 3/3] add docs --- docs/client/magic-authentication.md | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/client/magic-authentication.md diff --git a/docs/client/magic-authentication.md b/docs/client/magic-authentication.md new file mode 100644 index 00000000..8370f2d4 --- /dev/null +++ b/docs/client/magic-authentication.md @@ -0,0 +1,97 @@ +--- +title: Magic Authentication +order: 5 +--- + +# Sharing sites with magic authentication + +Expose allows you to protect your shared sites with a simple email-based authentication flow called "magic authentication". Instead of a browser popup asking for credentials, visitors see a clean login form where they enter their email address. Once submitted, a secure cookie is set allowing access on subsequent requests. + +This provides a more user-friendly authentication experience compared to basic authentication, while still providing security for your shared sites. + +## Using magic authentication + +To share your site with magic authentication that accepts any email address, use the `--magic-auth` flag: + +```bash +expose share my-site.test --magic-auth +``` + +When someone visits your shared URL, they'll see a login form asking for their email address. After entering a valid email, they'll be redirected to the original page and can browse freely for the next 7 days. + +## Restricting access by email domain + +You can restrict access to specific email domains using the `@domain.com` pattern: + +```bash +# Only allow emails from @company.com +expose share my-site.test --magic-auth=@company.com +``` + +This is useful when sharing development sites with your team - only team members with company email addresses can access the site. + +## Restricting access to multiple domains + +Separate multiple patterns with commas: + +```bash +# Allow emails from @company.com and @partner.com +expose share my-site.test --magic-auth=@company.com,@partner.com +``` + +## Allowing specific email addresses + +You can also allow specific email addresses: + +```bash +# Only allow these specific users +expose share my-site.test --magic-auth=alice@example.com,bob@example.com +``` + +## Combining domains and specific emails + +Mix domain patterns and specific email addresses as needed: + +```bash +# Allow anyone from @company.com plus a specific contractor +expose share my-site.test --magic-auth=@company.com,contractor@external.com +``` + +## How it works + +1. When a visitor accesses your shared URL without a valid authentication cookie, they see a login form +2. They enter their email address and submit the form +3. If the email matches the allowed patterns (or any email is allowed), a signed cookie is set +4. The visitor is redirected to the original page they requested +5. Future requests include the cookie and pass through to your local site +6. The authentication cookie expires after 7 days + +## Connection status + +When magic authentication is enabled, the connection table shows the current auth configuration: + +``` +┌────────────┬──────────────────────────────────────────────┐ +│ Shared site│ my-site.test │ +│ Dashboard │ http://127.0.0.1:4040 │ +│ Public URL │ https://my-site.sharedwithexpose.com │ +│ Magic Auth │ Enabled (@company.com) │ +└────────────┴──────────────────────────────────────────────┘ +``` + +## Combining with other options + +Magic authentication works with other sharing options: + +```bash +# Custom subdomain with magic auth +expose share my-site.test --subdomain=demo --magic-auth=@company.com + +# With custom domain +expose share my-site.test --domain=mycompany.com --magic-auth + +# With QR code +expose share my-site.test --magic-auth --qr-code +``` + +> **Note**: Magic authentication cannot be combined with basic authentication (`--auth`). Choose one authentication method per share session.