From c8eae51a73b687f6ab262dc602f4364bab0c440b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:29 +0800 Subject: [PATCH 01/10] feat(socket-auth): socket tokens, principals and channel authorization Adds the realtime channel authentication core, switched on by SOCKETCLUSTER_AUTH_KEY (config auth_key, publish_url, token_ttl): - SocketToken mints and verifies HS256 socket tokens (iss/aud/iat/nbf/exp/jti plus kind, sub, cid, cpid, env, ids, adm, scp, sid); anything not HS256 with the configured key, or with a wrong issuer/audience or out-of-range time claims, is rejected. Includes the scoped public tracking token. - SocketSignature derives per-purpose HMAC keys and signs/verifies the timestamped requests exchanged with the socket server. - SocketPrincipal, ChannelDecision, the SocketChannelResolver contract and the SocketChannelRegistry extensions register their channel prefixes with. - ChannelAuthorizer applies install, expiry, scope, system and self rules, then the prefix resolver, caching decisions per token and channel. - Core resolvers for company, api, user, test, install/uninstall, chat, chat_channel, chat_participant, chat_message and file channels. - The registry and authorizer are container singletons. --- config/broadcasting.connections.php | 6 + src/Contracts/SocketChannelResolver.php | 17 + .../SocketClusterServiceProvider.php | 25 ++ .../SocketCluster/ChannelAuthorizer.php | 154 +++++++ src/Support/SocketCluster/ChannelDecision.php | 52 +++ .../SocketCluster/CoreChannelResolvers.php | 149 +++++++ .../SocketCluster/ModelChannelResolver.php | 64 +++ .../SocketCluster/SocketChannelRegistry.php | 79 ++++ src/Support/SocketCluster/SocketPrincipal.php | 199 +++++++++ src/Support/SocketCluster/SocketSignature.php | 77 ++++ src/Support/SocketCluster/SocketToken.php | 214 ++++++++++ tests/Fixtures/Support/SocketAuthFixtures.php | 324 +++++++++++++++ .../Providers/CoreProviderContractsTest.php | 20 + .../SocketChannelAuthorizationTest.php | 372 +++++++++++++++++ tests/Unit/Support/SocketTokenTest.php | 381 ++++++++++++++++++ 15 files changed, 2133 insertions(+) create mode 100644 src/Contracts/SocketChannelResolver.php create mode 100644 src/Support/SocketCluster/ChannelAuthorizer.php create mode 100644 src/Support/SocketCluster/ChannelDecision.php create mode 100644 src/Support/SocketCluster/CoreChannelResolvers.php create mode 100644 src/Support/SocketCluster/ModelChannelResolver.php create mode 100644 src/Support/SocketCluster/SocketChannelRegistry.php create mode 100644 src/Support/SocketCluster/SocketPrincipal.php create mode 100644 src/Support/SocketCluster/SocketSignature.php create mode 100644 src/Support/SocketCluster/SocketToken.php create mode 100644 tests/Fixtures/Support/SocketAuthFixtures.php create mode 100644 tests/Unit/Support/SocketChannelAuthorizationTest.php create mode 100644 tests/Unit/Support/SocketTokenTest.php diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index bfe48c88..214514e3 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -23,6 +23,12 @@ 'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'), 'query' => [], ], + + // Realtime channel authentication. Leaving SOCKETCLUSTER_AUTH_KEY unset keeps the + // feature off: no socket tokens are minted and broadcasts use the websocket publisher. + 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), + 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), + 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), ], // for apple apn diff --git a/src/Contracts/SocketChannelResolver.php b/src/Contracts/SocketChannelResolver.php new file mode 100644 index 00000000..198e346b --- /dev/null +++ b/src/Contracts/SocketChannelResolver.php @@ -0,0 +1,17 @@ +app->singleton(SocketChannelRegistry::class, function () { + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + return $registry; + }); + + $this->app->singleton(ChannelAuthorizer::class, function ($app) { + return new ChannelAuthorizer($app->make(SocketChannelRegistry::class)); + }); + } + /** * Register new BroadcastManager in boot. * diff --git a/src/Support/SocketCluster/ChannelAuthorizer.php b/src/Support/SocketCluster/ChannelAuthorizer.php new file mode 100644 index 00000000..5c0e7d34 --- /dev/null +++ b/src/Support/SocketCluster/ChannelAuthorizer.php @@ -0,0 +1,154 @@ +instanceHasUsers() + ? ChannelDecision::allowed('install_pending', ChannelDecision::DENY_TTL) + : ChannelDecision::denied('no_token'); + } + + $remaining = $principal->secondsRemaining(); + + if ($remaining !== null && $remaining <= 0) { + return ChannelDecision::denied('expired'); + } + + $cacheKey = $principal->jti === null ? null : self::CACHE_PREFIX . sha1($principal->jti . '|' . $channel); + $cached = $cacheKey === null ? null : Cache::get($cacheKey); + + if (is_array($cached)) { + return ChannelDecision::fromArray($cached); + } + + $decision = $this->decide($principal, $channel); + + if ($decision->allow && $remaining !== null) { + $decision = $decision->capTtl($remaining); + } + + if ($cacheKey !== null) { + Cache::put($cacheKey, $decision->toArray(), $decision->ttl); + } + + return $decision; + } + + /** + * A channel name the socket server accepts: non-empty, at most 255 characters, no whitespace. + */ + public static function isValidChannel(string $channel): bool + { + return $channel !== '' && strlen($channel) <= self::MAX_CHANNEL_LENGTH && !preg_match('/\s/', $channel); + } + + /** + * The channels a principal may always follow without a lookup. + */ + public static function isSelfChannel(SocketPrincipal $principal, string $channel): bool + { + $own = []; + + if ($principal->isCompanyScoped()) { + $own[] = 'company.' . $principal->cid; + $own[] = 'company.' . $principal->cpid; + } + + if ($principal->kind === 'api') { + $own[] = 'api.' . $principal->sub; + } + + foreach ($principal->ids as $id) { + $own[] = 'user.' . $id; + $own[] = 'driver.' . $id; + } + + // Empty ids would yield names like "company." which no real channel has. + if (in_array($channel, array_filter($own, fn ($name) => !str_ends_with($name, '.')), true)) { + return true; + } + + return $principal->kind === 'user' + && $principal->cid !== null + && (str_starts_with($channel, 'install.' . $principal->cid . '.') || str_starts_with($channel, 'uninstall.' . $principal->cid . '.')); + } + + protected function decide(SocketPrincipal $principal, string $channel): ChannelDecision + { + if ($principal->scp !== null) { + return in_array($channel, $principal->scp, true) ? ChannelDecision::allowed('scope') : ChannelDecision::denied('out_of_scope'); + } + + if ($principal->isSystem()) { + return ChannelDecision::allowed('system'); + } + + if (static::isSelfChannel($principal, $channel)) { + return ChannelDecision::allowed('self'); + } + + $separator = strpos($channel, '.'); + $prefix = $separator === false ? $channel : substr($channel, 0, $separator); + $id = $separator === false ? '' : substr($channel, $separator + 1); + $resolver = $this->registry->resolve($prefix); + + if ($resolver === null || $id === '') { + return ChannelDecision::denied('unknown_prefix'); + } + + try { + $allowed = $resolver instanceof SocketChannelResolver + ? $resolver->authorize($principal, $id, $channel) + : $resolver($principal, $id, $channel); + } catch (\Throwable $e) { + Log::warning('Socket channel resolver failed.', ['prefix' => $prefix, 'error' => $e->getMessage()]); + + return ChannelDecision::denied('resolver_error'); + } + + return $allowed ? ChannelDecision::allowed('resolver') : ChannelDecision::denied('forbidden'); + } + + /** + * Whether setup has created a user yet. An unreachable or unmigrated database counts as not yet. + */ + protected function instanceHasUsers(): bool + { + try { + return User::query()->exists(); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/Support/SocketCluster/ChannelDecision.php b/src/Support/SocketCluster/ChannelDecision.php new file mode 100644 index 00000000..1de9b555 --- /dev/null +++ b/src/Support/SocketCluster/ChannelDecision.php @@ -0,0 +1,52 @@ +allow, max(1, min($this->ttl, $seconds)), $this->reason); + } + + public function toArray(): array + { + return [ + 'allow' => $this->allow, + 'ttl' => $this->ttl, + 'reason' => $this->reason, + ]; + } +} diff --git a/src/Support/SocketCluster/CoreChannelResolvers.php b/src/Support/SocketCluster/CoreChannelResolvers.php new file mode 100644 index 00000000..ac8c9233 --- /dev/null +++ b/src/Support/SocketCluster/CoreChannelResolvers.php @@ -0,0 +1,149 @@ +register('company', [static::class, 'company']); + $registry->register('api', [static::class, 'api']); + $registry->register('user', [static::class, 'user']); + $registry->register('test', [static::class, 'test']); + $registry->register('install', [static::class, 'install']); + $registry->register('uninstall', [static::class, 'install']); + $registry->registerModel('chat', ChatChannel::class, [static::class, 'participatesInChannel']); + $registry->registerModel('chat_channel', ChatChannel::class, [static::class, 'participatesInChannel']); + $registry->registerModel('chat_participant', ChatParticipant::class, [static::class, 'isParticipant']); + $registry->registerModel('chat_message', ChatMessage::class, [static::class, 'participatesInMessage']); + $registry->registerModel('file', File::class); + } + + /** + * `company.{uuid|public_id}`: the principal's own company. + */ + public static function company(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + $company = ModelChannelResolver::find(Company::class, $id, $principal); + + return $company !== null && $company->uuid === $principal->cid; + } + + /** + * `api.{id}`: an API credential of the principal's company, or the id of a personal access + * token owned by one of its users. + */ + public static function api(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + if (ctype_digit($id)) { + $token = PersonalAccessToken::on(ModelChannelResolver::connection($principal))->find((int) $id); + + return $token !== null && $token->tokenable instanceof User && static::isMember($principal, $token->tokenable->uuid); + } + + $credential = ApiCredential::on(ModelChannelResolver::connection($principal))->where('uuid', $id)->first() + ?? ApiCredential::on($principal->env === 'test' ? null : 'sandbox')->where('uuid', $id)->first(); + + return $credential !== null && $credential->company_uuid === $principal->cid; + } + + /** + * `user.{uuid|public_id}`: a member of the principal's company. Drivers and customers only + * reach their own user channel, which the local self rules already allow. + */ + public static function user(SocketPrincipal $principal, string $id): bool + { + if (!$principal->isCompanyScoped() || $principal->cid === null) { + return false; + } + + $user = ModelChannelResolver::find(User::class, $id, $principal); + + return $user !== null && static::isMember($principal, $user->uuid); + } + + /** + * `test.{user uuid}`: the admin socket test channel, for that user or a system admin. + */ + public static function test(SocketPrincipal $principal, string $id): bool + { + return $principal->adm || $principal->owns($id); + } + + /** + * `install.{company uuid}.*` and `uninstall.{company uuid}.*`: extension install progress. + */ + public static function install(SocketPrincipal $principal, string $id): bool + { + return $principal->isCompanyScoped() && $principal->cid !== null && str_starts_with($id, $principal->cid . '.'); + } + + public static function participatesInChannel(SocketPrincipal $principal, ChatChannel $chatChannel): bool + { + return static::isChatParticipant($principal, $chatChannel->uuid); + } + + public static function isParticipant(SocketPrincipal $principal, ChatParticipant $participant): bool + { + return $principal->owns((string) $participant->user_uuid); + } + + public static function participatesInMessage(SocketPrincipal $principal, ChatMessage $message): bool + { + return static::isChatParticipant($principal, $message->chat_channel_uuid); + } + + /** + * Whether one of the principal's own ids takes part in the chat channel. + */ + public static function isChatParticipant(SocketPrincipal $principal, ?string $chatChannelUuid): bool + { + if (!$chatChannelUuid || $principal->ids === []) { + return false; + } + + return ChatParticipant::on(ModelChannelResolver::connection($principal)) + ->where('chat_channel_uuid', $chatChannelUuid) + ->whereIn('user_uuid', $principal->ids) + ->exists(); + } + + /** + * Whether the user belongs to the principal's company. + */ + public static function isMember(SocketPrincipal $principal, ?string $userUuid): bool + { + if (!$userUuid || $principal->cid === null) { + return false; + } + + $connection = ModelChannelResolver::connection($principal); + + return CompanyUser::on($connection)->where('user_uuid', $userUuid)->where('company_uuid', $principal->cid)->exists() + || User::on($connection)->where('uuid', $userUuid)->where('company_uuid', $principal->cid)->exists(); + } +} diff --git a/src/Support/SocketCluster/ModelChannelResolver.php b/src/Support/SocketCluster/ModelChannelResolver.php new file mode 100644 index 00000000..583df828 --- /dev/null +++ b/src/Support/SocketCluster/ModelChannelResolver.php @@ -0,0 +1,64 @@ +narrow = $narrow; + } + + public function authorize(SocketPrincipal $principal, string $id, string $channel): bool + { + $narrowed = $principal->kind === 'driver' || $principal->kind === 'customer'; + + if (!$principal->isCompanyScoped() && !$narrowed) { + return false; + } + + $model = static::find($this->modelClass, $id, $principal); + + if ($model === null) { + return false; + } + + if ($principal->isCompanyScoped()) { + return $principal->cid !== null && $model->company_uuid === $principal->cid; + } + + return $this->narrow !== null && (bool) call_user_func($this->narrow, $principal, $model); + } + + /** + * Find a model by uuid or public_id in the principal's environment (sandbox for test). + */ + public static function find(string $modelClass, string $id, SocketPrincipal $principal): ?object + { + return $modelClass::on(static::connection($principal)) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + } + + /** + * The database connection for the principal's environment; null means the default one. + */ + public static function connection(SocketPrincipal $principal): ?string + { + return $principal->env === 'test' ? 'sandbox' : null; + } +} diff --git a/src/Support/SocketCluster/SocketChannelRegistry.php b/src/Support/SocketCluster/SocketChannelRegistry.php new file mode 100644 index 00000000..dad4f476 --- /dev/null +++ b/src/Support/SocketCluster/SocketChannelRegistry.php @@ -0,0 +1,79 @@ +registerModel('order', Order::class, $narrow); + */ +class SocketChannelRegistry +{ + /** + * @var array + */ + protected array $resolvers = []; + + /** + * @var array + */ + protected array $principalResolvers = []; + + /** + * Register the resolver for a prefix: fn (SocketPrincipal $p, string $id, string $channel): bool. + * + * A later registration for the same prefix replaces the earlier one. + */ + public function register(string $prefix, callable|SocketChannelResolver $resolver): void + { + $this->resolvers[$prefix] = $resolver; + } + + /** + * Register a prefix whose id is a model's uuid or public_id. + * + * User and API principals are allowed when the model belongs to their company. Driver and + * customer principals are allowed only when $narrow (fn (SocketPrincipal $p, $model): bool) + * says so; without it they are denied. Every other kind is denied. + */ + public function registerModel(string $prefix, string $modelClass, ?callable $narrow = null): void + { + $this->register($prefix, new ModelChannelResolver($modelClass, $narrow)); + } + + /** + * Register a resolver that may claim a Sanctum-authenticated user as a more specific principal: + * fn (Request $request, $user): ?SocketPrincipal. The first non-null answer wins. + */ + public function registerPrincipalResolver(callable $resolver): void + { + $this->principalResolvers[] = $resolver; + } + + public function resolve(string $prefix): callable|SocketChannelResolver|null + { + return $this->resolvers[$prefix] ?? null; + } + + /** + * The principal a registered resolver claims for the user, or null when none does. + */ + public function resolvePrincipal(Request $request, $user): ?SocketPrincipal + { + foreach ($this->principalResolvers as $resolver) { + $principal = $resolver($request, $user); + + if ($principal instanceof SocketPrincipal) { + return $principal; + } + } + + return null; + } +} diff --git a/src/Support/SocketCluster/SocketPrincipal.php b/src/Support/SocketCluster/SocketPrincipal.php new file mode 100644 index 00000000..649358fc --- /dev/null +++ b/src/Support/SocketCluster/SocketPrincipal.php @@ -0,0 +1,199 @@ +company_uuid; + + return new self( + kind: 'user', + sub: (string) $user->uuid, + cid: self::stringOrNull($cid), + cpid: self::companyPublicId($cid), + env: 'live', + ids: self::stringList([$user->uuid, $user->public_id]), + adm: $user->isAdmin() + ); + } + + /** + * An API credential; test-mode credentials act on the sandbox environment. + */ + public static function forApiCredential(ApiCredential $credential): self + { + return new self( + kind: 'api', + sub: (string) $credential->uuid, + cid: self::stringOrNull($credential->company_uuid), + cpid: self::companyPublicId($credential->company_uuid, $credential->getConnectionName()), + env: $credential->test_mode ? 'test' : 'live', + ids: self::stringList([$credential->uuid]) + ); + } + + /** + * The platform itself, which may subscribe to any channel. + */ + public static function system(): self + { + return new self(kind: 'system', sub: 'system'); + } + + /** + * The claims this principal contributes to a token, without the unset optional ones. + */ + public function toClaims(): array + { + return array_filter([ + 'kind' => $this->kind, + 'sub' => $this->sub, + 'cid' => $this->cid, + 'cpid' => $this->cpid, + 'env' => $this->env, + 'ids' => $this->ids, + 'adm' => $this->adm, + 'scp' => $this->scp, + 'sid' => $this->sid, + 'jti' => $this->jti, + 'exp' => $this->exp, + ], fn ($value) => $value !== null); + } + + /** + * A copy of this principal with the given properties replaced. + */ + public function with(array $changes): self + { + return new self(...array_merge([ + 'kind' => $this->kind, + 'sub' => $this->sub, + 'cid' => $this->cid, + 'cpid' => $this->cpid, + 'env' => $this->env, + 'ids' => $this->ids, + 'adm' => $this->adm, + 'scp' => $this->scp, + 'sid' => $this->sid, + 'jti' => $this->jti, + 'exp' => $this->exp, + ], $changes)); + } + + public function isSystem(): bool + { + return $this->kind === 'system'; + } + + public function isCompanyScoped(): bool + { + return $this->kind === 'user' || $this->kind === 'api'; + } + + public function owns(string $id): bool + { + return $id !== '' && in_array($id, $this->ids, true); + } + + /** + * Seconds until the token this principal came from expires, or null when it carries no expiry. + */ + public function secondsRemaining(): ?int + { + return $this->exp === null ? null : $this->exp - Carbon::now()->getTimestamp(); + } + + private static function companyPublicId(?string $companyUuid, ?string $connection = null): ?string + { + if (!$companyUuid) { + return null; + } + + return self::stringOrNull(Company::on($connection)->where('uuid', $companyUuid)->value('public_id')); + } + + private static function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + private static function stringList(mixed $values): array + { + return array_values(array_filter((array) $values, fn ($value) => is_string($value) && $value !== '')); + } + + private static function timestamp(mixed $value): ?int + { + if ($value instanceof \DateTimeInterface) { + return $value->getTimestamp(); + } + + return is_numeric($value) ? (int) $value : null; + } +} diff --git a/src/Support/SocketCluster/SocketSignature.php b/src/Support/SocketCluster/SocketSignature.php new file mode 100644 index 00000000..ffdae52d --- /dev/null +++ b/src/Support/SocketCluster/SocketSignature.php @@ -0,0 +1,77 @@ +getTimestamp(); + + return [ + self::HEADER_TIMESTAMP => $timestamp, + self::HEADER_SIGNATURE => static::sign($purpose, $timestamp, $body), + ]; + } + + /** + * Whether a request's timestamp and signature are valid for the raw body, compared in constant time. + */ + public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body): bool + { + if (!SocketToken::enabled() || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { + return false; + } + + if (abs(Carbon::now()->getTimestamp() - (int) $timestamp) > self::TOLERANCE) { + return false; + } + + return hash_equals(static::sign($purpose, $timestamp, $body), strtolower($signature)); + } +} diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php new file mode 100644 index 00000000..57ae85b6 --- /dev/null +++ b/src/Support/SocketCluster/SocketToken.php @@ -0,0 +1,214 @@ += self::MIN_KEY_LENGTH ? $key : null; + } + + public static function enabled(): bool + { + return static::key() !== null; + } + + /** + * Mint a token for the principal and return the token endpoint response body. + * + * @return array{token: string, expires_in: int, expires_at: string} + */ + public static function issue(SocketPrincipal $principal, ?int $ttl = null): array + { + $configuration = static::configuration(); + $ttl = max(1, min(self::MAX_TTL, $ttl ?? static::defaultTtl($principal->kind))); + $now = Carbon::now()->getTimestamp(); + $issuedAt = new \DateTimeImmutable('@' . $now); + $expiresAt = new \DateTimeImmutable('@' . ($now + $ttl)); + + $builder = $configuration->builder() + ->issuedBy(self::ISSUER) + ->permittedFor(self::AUDIENCE) + ->identifiedBy((string) Str::uuid()) + ->relatedTo($principal->sub) + ->issuedAt($issuedAt) + ->canOnlyBeUsedAfter($issuedAt) + ->expiresAt($expiresAt); + + $claims = $principal->toClaims(); + unset($claims['sub'], $claims['jti'], $claims['exp']); + + foreach ($claims as $name => $value) { + $builder = $builder->withClaim($name, $value); + } + + return [ + 'token' => $builder->getToken($configuration->signer(), $configuration->signingKey())->toString(), + 'expires_in' => $ttl, + 'expires_at' => $expiresAt->format(DATE_ATOM), + ]; + } + + /** + * The principal a token was minted for, or null when it is not one of ours or no longer valid. + * + * Rejects anything not signed HS256 with the configured key (including alg "none" and + * asymmetric algorithms), a wrong issuer or audience, and missing or out-of-range iat/nbf/exp. + */ + public static function verify(string $jwt): ?SocketPrincipal + { + if ($jwt === '' || !static::enabled()) { + return null; + } + + try { + $configuration = static::configuration(); + $token = $configuration->parser()->parse($jwt); + + if (!$token instanceof Plain || $token->headers()->get('alg') !== self::ALGORITHM) { + return null; + } + + $configuration->validator()->assert( + $token, + new SignedWith($configuration->signer(), $configuration->verificationKey()), + new IssuedBy(self::ISSUER), + new PermittedFor(self::AUDIENCE), + new StrictValidAt(new FrozenClock(Carbon::now()->toDateTimeImmutable())) + ); + + return SocketPrincipal::fromClaims($token->claims()->all()); + } catch (\Throwable $e) { + return null; + } + } + + /** + * A short-lived token for the platform itself, which may subscribe to any channel. + */ + public static function system(): string + { + return static::issue(SocketPrincipal::system())['token']; + } + + /** + * Mint the scoped token for one customer's public tracking channel. + * + * Accepts FleetOps' TrackingScope (order_uuid, customer_type, customer_uuid). The token + * may subscribe to `tracking.{opaque}` and nothing else. + * + * @return array{token: string, expires_in: int, expires_at: string} + */ + public static function forTracking(object $scope): array + { + $orderUuid = (string) data_get($scope, 'order_uuid'); + $trackingId = static::trackingId($orderUuid, (string) data_get($scope, 'customer_type'), (string) data_get($scope, 'customer_uuid')); + $companyId = data_get($scope, 'company_uuid') ?: DB::table('orders')->where('uuid', $orderUuid)->value('company_uuid'); + + return static::issue(new SocketPrincipal( + kind: 'tracking', + sub: $trackingId, + cid: is_string($companyId) && $companyId !== '' ? $companyId : null, + scp: ['tracking.' . $trackingId] + ), self::TRACKING_TTL); + } + + /** + * The opaque id of a customer's public tracking channel: lowercase unpadded base32 of + * HMAC-SHA256(tracking key, "{order_uuid}:{customer_type}:{customer_uuid}"), first 26 characters. + */ + public static function trackingId(string $orderUuid, string $customerType, string $customerUuid): string + { + $digest = hash_hmac('sha256', $orderUuid . ':' . $customerType . ':' . $customerUuid, SocketSignature::deriveKey(SocketSignature::TRACKING), true); + + return substr(static::base32($digest), 0, self::TRACKING_ID_LENGTH); + } + + /** + * RFC 4648 base32, lowercase and without padding. + */ + public static function base32(string $bytes): string + { + $alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + $bits = ''; + $encoded = ''; + + foreach (str_split($bytes) as $byte) { + $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT); + } + + foreach (str_split($bits, 5) as $chunk) { + $encoded .= $alphabet[bindec(str_pad($chunk, 5, '0', STR_PAD_RIGHT))]; + } + + return $encoded; + } + + public static function defaultTtl(string $kind): int + { + $configured = (int) config('broadcasting.connections.socketcluster.token_ttl', self::DEFAULT_TTL); + + return match ($kind) { + 'tracking' => self::TRACKING_TTL, + 'system' => self::SYSTEM_TTL, + default => $configured > 0 ? $configured : self::DEFAULT_TTL, + }; + } + + protected static function configuration(): Configuration + { + $key = static::key(); + + if ($key === null) { + throw new \RuntimeException('Socket authentication is not configured.'); + } + + return Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($key)); + } +} diff --git a/tests/Fixtures/Support/SocketAuthFixtures.php b/tests/Fixtures/Support/SocketAuthFixtures.php new file mode 100644 index 00000000..fb09a309 --- /dev/null +++ b/tests/Fixtures/Support/SocketAuthFixtures.php @@ -0,0 +1,324 @@ + $key, + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + 'broadcasting.connections.socketcluster.token_ttl' => 900, + 'broadcasting.connections.socketcluster.options' => [ + 'secure' => false, + 'host' => 'socket.test', + 'port' => 8000, + 'path' => '/socketcluster/', + 'query' => [], + ], + ], $config)); + $container->instance(HttpFactory::class, new HttpFactory()); + Facade::clearResolvedInstances(); + Carbon::setTestNow(Carbon::createFromTimestampUTC(self::NOW)); + session()->flush(); + + return $container; + } + + /** + * Undo container(): a fresh container (so socket authentication is off again), and the + * clock, session and booted models released. + */ + public static function reset(): void + { + Carbon::setTestNow(); + session()->flush(); + EloquentModel::clearBootedModels(); + Container::setInstance(new \FleetbaseTestContainer()); + Facade::clearResolvedInstances(); + } + + /** + * Seeded databases for resolver, principal and controller tests. + * + * @param array $skipTables tables to leave out, to exercise missing-schema paths + */ + public static function database(?string $key = self::KEY, array $skipTables = [], bool $seed = true): Capsule + { + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = static::container($key, [ + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'database.connections.sandbox' => $connection, + 'fleetbase.connection.db' => 'mysql', + ]); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->addConnection($connection, 'sandbox'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + foreach (['mysql', 'sandbox'] as $name) { + static::createSchema($capsule, $name, $skipTables); + } + + if ($seed) { + static::seed($capsule); + } + + return $capsule; + } + + /** + * An unsigned or HS256-signed JWT with exactly the given header and claims. + */ + public static function jwt(array $header, array $claims, ?string $key = null): string + { + $unsigned = static::base64Url(json_encode($header)) . '.' . static::base64Url(json_encode($claims)); + $signature = $key === null ? '' : static::base64Url(hash_hmac('sha256', $unsigned, $key, true)); + + return $unsigned . '.' . $signature; + } + + /** + * Valid claims for a hand-built token, before any test-specific changes. + */ + public static function claims(array $overrides = []): array + { + return array_merge([ + 'iss' => 'fleetbase-api', + 'aud' => 'fleetbase-socket', + 'iat' => self::NOW, + 'nbf' => self::NOW, + 'exp' => self::NOW + 600, + 'jti' => 'jti-handmade', + 'sub' => 'user-a1', + 'kind' => 'user', + 'cid' => 'company-a', + 'env' => 'live', + 'ids' => ['user-a1'], + 'adm' => false, + ], $overrides); + } + + /** + * The decoded payload segment of a JWT. + */ + public static function payload(string $jwt): array + { + return json_decode(static::base64UrlDecode(explode('.', $jwt)[1]), true); + } + + /** + * The decoded header segment of a JWT. + */ + public static function header(string $jwt): array + { + return json_decode(static::base64UrlDecode(explode('.', $jwt)[0]), true); + } + + public static function user(array $attributes = []): User + { + $user = new User(); + $user->setRawAttributes(array_merge([ + 'uuid' => 'user-a1', + 'public_id' => 'user_a1', + 'company_uuid' => 'company-a', + 'type' => 'user', + ], $attributes)); + + return $user; + } + + protected static function base64Url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + protected static function base64UrlDecode(string $value): string + { + return base64_decode(strtr($value, '-_', '+/')); + } + + protected static function createSchema(Capsule $capsule, string $name, array $skipTables): void + { + $schema = $capsule->getConnection($name)->getSchemaBuilder(); + $tables = [ + 'companies' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + }, + 'users' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + }, + 'company_users' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + }, + 'api_credentials' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('key')->nullable(); + $table->boolean('test_mode')->default(false); + $table->timestamp('expires_at')->nullable(); + }, + 'personal_access_tokens' => function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $table->string('name')->nullable(); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + }, + 'chat_channels' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + }, + 'chat_participants' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + }, + 'chat_messages' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + }, + 'files' => function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + }, + 'orders' => function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + }, + ]; + + foreach ($tables as $table => $definition) { + if (in_array($table, $skipTables, true)) { + continue; + } + + $schema->create($table, function ($blueprint) use ($definition) { + $definition($blueprint); + $blueprint->timestamp('created_at')->nullable(); + $blueprint->timestamp('updated_at')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + } + + protected static function seed(Capsule $capsule): void + { + $live = $capsule->getConnection('mysql'); + $sandbox = $capsule->getConnection('sandbox'); + + $live->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaa', 'name' => 'Company A'], + ['uuid' => 'company-b', 'public_id' => 'company_bbb', 'name' => 'Company B'], + ]); + $live->table('users')->insert([ + ['uuid' => 'user-a1', 'public_id' => 'user_a1', 'company_uuid' => 'company-a', 'type' => 'user'], + ['uuid' => 'user-a2', 'public_id' => 'user_a2', 'company_uuid' => 'company-a', 'type' => 'user'], + ['uuid' => 'user-b1', 'public_id' => 'user_b1', 'company_uuid' => 'company-b', 'type' => 'user'], + ]); + // user-a2 has no company_users row: membership then falls back to users.company_uuid. + $live->table('company_users')->insert([ + ['uuid' => 'company-user-a1', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1'], + ['uuid' => 'company-user-b1', 'company_uuid' => 'company-b', 'user_uuid' => 'user-b1'], + ]); + $live->table('api_credentials')->insert([ + ['uuid' => 'cred-a', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1', 'key' => 'flb_live_a', 'test_mode' => false], + ['uuid' => 'cred-b', 'company_uuid' => 'company-b', 'user_uuid' => 'user-b1', 'key' => 'flb_live_b', 'test_mode' => false], + ]); + $live->table('personal_access_tokens')->insert([ + ['id' => 1, 'tokenable_type' => User::class, 'tokenable_id' => 'user-a1', 'name' => 'navigator', 'token' => hash('sha256', 'plain-token-a1'), 'abilities' => '["*"]'], + ['id' => 2, 'tokenable_type' => User::class, 'tokenable_id' => 'user-b1', 'name' => 'navigator', 'token' => hash('sha256', 'plain-token-b1'), 'abilities' => '["*"]'], + ]); + $live->table('chat_channels')->insert([ + ['uuid' => 'chat-a', 'public_id' => 'chat_aaa', 'company_uuid' => 'company-a'], + ['uuid' => 'chat-b', 'public_id' => 'chat_bbb', 'company_uuid' => 'company-b'], + ]); + $live->table('chat_participants')->insert([ + ['uuid' => 'participant-a1', 'public_id' => 'chat_participant_a1', 'company_uuid' => 'company-a', 'chat_channel_uuid' => 'chat-a', 'user_uuid' => 'user-a1'], + ['uuid' => 'participant-b1', 'public_id' => 'chat_participant_b1', 'company_uuid' => 'company-b', 'chat_channel_uuid' => 'chat-b', 'user_uuid' => 'user-b1'], + ]); + $live->table('chat_messages')->insert([ + ['uuid' => 'message-a', 'public_id' => 'chat_message_a', 'company_uuid' => 'company-a', 'chat_channel_uuid' => 'chat-a'], + ['uuid' => 'message-b', 'public_id' => 'chat_message_b', 'company_uuid' => 'company-b', 'chat_channel_uuid' => 'chat-b'], + ]); + $live->table('files')->insert([ + ['uuid' => 'file-a', 'public_id' => 'file_aaa', 'company_uuid' => 'company-a'], + ['uuid' => 'file-b', 'public_id' => 'file_bbb', 'company_uuid' => 'company-b'], + ]); + $live->table('orders')->insert([ + ['uuid' => 'order-a', 'company_uuid' => 'company-a'], + ]); + + // The sandbox carries synced companies and its own test-mode records. + $sandbox->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaa', 'name' => 'Company A'], + ]); + $sandbox->table('api_credentials')->insert([ + ['uuid' => 'cred-a-test', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a1', 'key' => 'flb_test_a', 'test_mode' => true], + ]); + $sandbox->table('files')->insert([ + ['uuid' => 'file-a-test', 'public_id' => 'file_aaa_test', 'company_uuid' => 'company-a'], + ]); + } +} diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 7bf593a0..68d485a3 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -148,6 +148,9 @@ interface ShouldQueue use Fleetbase\Services\TemplateRenderService; use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\Reporting\ReportSchemaRegistry; + use Fleetbase\Support\SocketCluster\ChannelAuthorizer; + use Fleetbase\Support\SocketCluster\ModelChannelResolver; + use Fleetbase\Support\SocketCluster\SocketChannelRegistry; use Fleetbase\Support\SocketCluster\SocketClusterBroadcaster; use Fleetbase\Webhook\Events\FinalWebhookCallFailedEvent; use Fleetbase\Webhook\Events\WebhookCallFailedEvent; @@ -1012,6 +1015,23 @@ class_alias(CoreProviderContractsFailingMixinMacro::class, 'Fleetbase\\ProviderF Facade::clearResolvedInstance('Broadcast'); }); + test('socket cluster provider shares one channel registry with core resolvers and one authorizer', function () { + $container = bind_test_container(); + + (new SocketClusterServiceProvider($container))->register(); + + $registry = $container->make(SocketChannelRegistry::class); + + expect($container->make(SocketChannelRegistry::class))->toBe($registry) + ->and($registry->resolve('chat'))->toBeInstanceOf(ModelChannelResolver::class) + ->and($registry->resolve('company'))->not->toBeNull() + ->and($container->make(ChannelAuthorizer::class))->toBeInstanceOf(ChannelAuthorizer::class) + ->and($container->make(ChannelAuthorizer::class))->toBe($container->make(ChannelAuthorizer::class)); + + $container->offsetUnset(SocketChannelRegistry::class); + $container->offsetUnset(ChannelAuthorizer::class); + }); + test('webhook server provider configures package name and config file', function () { $package = new Package(); diff --git a/tests/Unit/Support/SocketChannelAuthorizationTest.php b/tests/Unit/Support/SocketChannelAuthorizationTest.php new file mode 100644 index 00000000..b539ddc8 --- /dev/null +++ b/tests/Unit/Support/SocketChannelAuthorizationTest.php @@ -0,0 +1,372 @@ +calls[] = [$principal->sub, $id, $channel]; + + return $this->answer; + } +} + +/** + * A company A console user by default; overrides replace any constructor argument. + */ +function socket_channel_principal(array $overrides = []): SocketPrincipal +{ + return new SocketPrincipal(...array_merge([ + 'kind' => 'user', + 'sub' => 'user-a1', + 'cid' => 'company-a', + 'cpid' => 'company_aaa', + 'ids' => ['user-a1', 'user_a1'], + 'jti' => null, + 'exp' => SocketAuthFixtures::NOW + 900, + ], $overrides)); +} + +function socket_channel_driver(array $overrides = []): SocketPrincipal +{ + return socket_channel_principal(array_merge([ + 'kind' => 'driver', + 'sub' => 'driver-1', + 'cpid' => null, + 'ids' => ['driver-1', 'driver_1', 'user-a1'], + ], $overrides)); +} + +function socket_channel_core_authorizer(): ChannelAuthorizer +{ + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + return new ChannelAuthorizer($registry); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('the authorizer denies malformed channel names before anything else', function (string $channel) { + SocketAuthFixtures::container(); + + $decision = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(SocketPrincipal::system(), $channel); + + expect($decision->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_channel']); +})->with([ + 'empty' => [''], + 'whitespace' => ['order.order 1'], + 'too long' => [str_repeat('a', 256)], +]); + +test('anonymous connections may follow the install channel only until setup creates a user', function () { + SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users']); + $missingSchema = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); + + SocketAuthFixtures::database(SocketAuthFixtures::KEY, [], false); + $noUsers = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); + + SocketAuthFixtures::database(); + $authorizer = new ChannelAuthorizer(new SocketChannelRegistry()); + + expect($missingSchema->toArray())->toBe(['allow' => true, 'ttl' => 30, 'reason' => 'install_pending']) + ->and($noUsers->toArray())->toBe(['allow' => true, 'ttl' => 30, 'reason' => 'install_pending']) + ->and($authorizer->authorize(null, 'fleetbase.install')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'no_token']) + ->and($authorizer->authorize(null, 'company.company_aaa')->reason)->toBe('no_token'); +}); + +test('expired principals are denied', function () { + SocketAuthFixtures::container(); + + $authorizer = new ChannelAuthorizer(new SocketChannelRegistry()); + + expect($authorizer->authorize(socket_channel_principal(['exp' => SocketAuthFixtures::NOW - 1]), 'company.company-a')->reason)->toBe('expired') + ->and($authorizer->authorize(socket_channel_principal(['exp' => SocketAuthFixtures::NOW]), 'company.company-a')->reason)->toBe('expired') + ->and($authorizer->authorize(SocketPrincipal::system(), 'company.company-a')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'system']); +}); + +test('scoped tokens may follow exactly their listed channels and nothing else', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $resolver = new SocketChannelAuthorizationRecordingResolver(true); + $registry->register('checkout', $resolver); + $registry->register('company', $resolver); + + $authorizer = new ChannelAuthorizer($registry); + $checkout = new SocketPrincipal(kind: 'checkout', sub: 'checkout-1', cid: 'company-a', scp: ['checkout.checkout_1'], exp: SocketAuthFixtures::NOW + 900); + $system = SocketPrincipal::system()->with(['scp' => ['tracking.abc']]); + + expect($authorizer->authorize($checkout, 'checkout.checkout_1')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'scope']) + ->and($authorizer->authorize($checkout, 'checkout.checkout_2')->reason)->toBe('out_of_scope') + ->and($authorizer->authorize($checkout, 'company.company-a')->reason)->toBe('out_of_scope') + ->and($authorizer->authorize($system, 'order.order_1')->reason)->toBe('out_of_scope') + ->and($resolver->calls)->toBe([]); +}); + +test('principals follow their own channels without a lookup', function () { + SocketAuthFixtures::container(); + + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + $driver = socket_channel_driver(); + $noCpid = socket_channel_principal(['cpid' => null]); + + foreach (['company.company-a', 'company.company_aaa', 'user.user-a1', 'user.user_a1', 'driver.user_a1', 'install.company-a.fleetops', 'uninstall.company-a.fleetops'] as $channel) { + expect(ChannelAuthorizer::isSelfChannel($user, $channel))->toBeTrue(); + } + + expect(ChannelAuthorizer::isSelfChannel($api, 'api.cred-a'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($api, 'company.company-a'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($api, 'install.company-a.fleetops'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'api.user-a1'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'install.company-b.fleetops'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($user, 'user.user-b1'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($noCpid, 'company.'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'driver.driver_1'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'user.user-a1'))->toBeTrue() + ->and(ChannelAuthorizer::isSelfChannel($driver, 'company.company-a'))->toBeFalse() + ->and(ChannelAuthorizer::isSelfChannel(socket_channel_principal(['cid' => null]), 'install..fleetops'))->toBeFalse() + ->and((new ChannelAuthorizer(new SocketChannelRegistry()))->authorize($driver, 'driver.driver-1')->reason)->toBe('self'); +}); + +test('other channels are decided by the resolver registered for their prefix', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $orders = new SocketChannelAuthorizationRecordingResolver(true); + $registry->register('order', $orders); + $registry->register('vehicle', function (SocketPrincipal $principal, string $id, string $channel) { + return $id === 'vehicle_1'; + }); + $registry->register('broken', function () { + throw new RuntimeException('lookup failed'); + }); + + $authorizer = new ChannelAuthorizer($registry); + $user = socket_channel_principal(); + + expect($authorizer->authorize($user, 'order.order_1.extra')->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'resolver']) + ->and($orders->calls)->toBe([['user-a1', 'order_1.extra', 'order.order_1.extra']]) + ->and($authorizer->authorize($user, 'vehicle.vehicle_1')->reason)->toBe('resolver') + ->and($authorizer->authorize($user, 'vehicle.vehicle_2')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($authorizer->authorize($user, 'broken.anything')->reason)->toBe('resolver_error') + ->and(app('log')->entries)->toBe([ + ['warning', 'Socket channel resolver failed.', ['prefix' => 'broken', 'error' => 'lookup failed']], + ]) + ->and($authorizer->authorize($user, 'unknown.anything')->reason)->toBe('unknown_prefix') + ->and($authorizer->authorize($user, 'order')->reason)->toBe('unknown_prefix') + ->and($authorizer->authorize($user, 'order.')->reason)->toBe('unknown_prefix'); +}); + +test('decisions are cached per token and channel for their capped lifetime', function () { + SocketAuthFixtures::container(); + + $registry = new SocketChannelRegistry(); + $orders = new SocketChannelAuthorizationRecordingResolver(true); + $vehicles = new SocketChannelAuthorizationRecordingResolver(false); + $registry->register('order', $orders); + $registry->register('vehicle', $vehicles); + + $authorizer = new ChannelAuthorizer($registry); + $principal = socket_channel_principal(['jti' => 'jti-cached', 'exp' => SocketAuthFixtures::NOW + 100]); + $first = $authorizer->authorize($principal, 'order.order_1'); + $second = $authorizer->authorize($principal, 'order.order_1'); + + $authorizer->authorize($principal, 'vehicle.vehicle_1'); + $denied = $authorizer->authorize($principal, 'vehicle.vehicle_1'); + + $uncached = socket_channel_principal(); + $authorizer->authorize($uncached, 'order.order_2'); + $authorizer->authorize($uncached, 'order.order_2'); + + expect($first->toArray())->toBe(['allow' => true, 'ttl' => 100, 'reason' => 'resolver']) + ->and($second->toArray())->toBe($first->toArray()) + ->and(app('cache')->get('socket-auth:' . sha1('jti-cached|order.order_1')))->toBe($first->toArray()) + ->and($denied->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($vehicles->calls)->toHaveCount(1) + ->and($orders->calls)->toBe([ + ['user-a1', 'order_1', 'order.order_1'], + ['user-a1', 'order_2', 'order.order_2'], + ['user-a1', 'order_2', 'order.order_2'], + ]); +}); + +test('the registry keeps one resolver per prefix and the first principal a resolver claims', function () { + $registry = new SocketChannelRegistry(); + $request = Request::create('/v1/socket/token', 'POST'); + $first = new SocketChannelAuthorizationRecordingResolver(true); + $second = new SocketChannelAuthorizationRecordingResolver(false); + + $registry->register('order', $first); + $registry->register('order', $second); + $registry->registerModel('file', File::class); + + expect($registry->resolve('order'))->toBe($second) + ->and($registry->resolve('file'))->toBeInstanceOf(ModelChannelResolver::class) + ->and($registry->resolve('missing'))->toBeNull() + ->and($registry->resolvePrincipal($request, 'user-a1'))->toBeNull(); + + $registry->registerPrincipalResolver(function (Request $request, $user) { + return null; + }); + $registry->registerPrincipalResolver(function (Request $request, $user) { + return 'not a principal'; + }); + $registry->registerPrincipalResolver(function (Request $request, $user) { + return new SocketPrincipal(kind: 'driver', sub: 'driver-1', cid: 'company-a', ids: ['driver-1', $user]); + }); + $registry->registerPrincipalResolver(function () { + throw new RuntimeException('a later resolver is never asked'); + }); + + expect($registry->resolvePrincipal($request, 'user-a1')->ids)->toBe(['driver-1', 'user-a1']); +}); + +test('model channels belong to their company and are narrowed for drivers and customers', function () { + SocketAuthFixtures::database(); + + $resolver = new ModelChannelResolver(File::class); + $narrowed = new ModelChannelResolver(File::class, function (SocketPrincipal $principal, File $file) { + return $file->uuid === 'file-a'; + }); + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + $driver = socket_channel_driver(); + $checkout = new SocketPrincipal(kind: 'checkout', sub: 'checkout-1', cid: 'company-a'); + $sandbox = socket_channel_principal(['env' => 'test']); + + expect($resolver->authorize($user, 'file_aaa', 'file.file_aaa'))->toBeTrue() + ->and($resolver->authorize($user, 'file-a', 'file.file-a'))->toBeTrue() + ->and($resolver->authorize($api, 'file-a', 'file.file-a'))->toBeTrue() + ->and($resolver->authorize($user, 'file_bbb', 'file.file_bbb'))->toBeFalse() + ->and($resolver->authorize($user, 'file-missing', 'file.file-missing'))->toBeFalse() + ->and($resolver->authorize(socket_channel_principal(['cid' => null]), 'file-a', 'file.file-a'))->toBeFalse() + ->and($resolver->authorize($driver, 'file-a', 'file.file-a'))->toBeFalse() + ->and($narrowed->authorize($driver, 'file-a', 'file.file-a'))->toBeTrue() + ->and($narrowed->authorize($driver, 'file-b', 'file.file-b'))->toBeFalse() + ->and($narrowed->authorize($checkout, 'file-a', 'file.file-a'))->toBeFalse() + ->and($resolver->authorize($sandbox, 'file_aaa_test', 'file.file_aaa_test'))->toBeTrue() + ->and($resolver->authorize($sandbox, 'file-a', 'file.file-a'))->toBeFalse() + ->and(ModelChannelResolver::connection($sandbox))->toBe('sandbox') + ->and(ModelChannelResolver::connection($user))->toBeNull(); +}); + +test('core registers its channel prefixes', function () { + $registry = new SocketChannelRegistry(); + CoreChannelResolvers::register($registry); + + foreach (['company', 'api', 'user', 'test', 'install', 'uninstall'] as $prefix) { + expect($registry->resolve($prefix))->toBeArray(); + } + + foreach (['chat', 'chat_channel', 'chat_participant', 'chat_message', 'file'] as $prefix) { + expect($registry->resolve($prefix))->toBeInstanceOf(ModelChannelResolver::class); + } +}); + +test('company and user channels resolve only within the principal company', function () { + SocketAuthFixtures::database(); + + $user = socket_channel_principal(); + $driver = socket_channel_driver(); + + expect(CoreChannelResolvers::company($user, 'company-a'))->toBeTrue() + ->and(CoreChannelResolvers::company($user, 'company_aaa'))->toBeTrue() + ->and(CoreChannelResolvers::company($user, 'company_bbb'))->toBeFalse() + ->and(CoreChannelResolvers::company($user, 'company-missing'))->toBeFalse() + ->and(CoreChannelResolvers::company($driver, 'company-a'))->toBeFalse() + ->and(CoreChannelResolvers::user($user, 'user_a1'))->toBeTrue() + ->and(CoreChannelResolvers::user($user, 'user-a2'))->toBeTrue() + ->and(CoreChannelResolvers::user($user, 'user_b1'))->toBeFalse() + ->and(CoreChannelResolvers::user($user, 'user-missing'))->toBeFalse() + ->and(CoreChannelResolvers::user($driver, 'user-a2'))->toBeFalse() + ->and(CoreChannelResolvers::isMember($user, null))->toBeFalse() + ->and(CoreChannelResolvers::isMember(socket_channel_principal(['cid' => null]), 'user-a1'))->toBeFalse(); +}); + +test('api channels resolve to credentials and personal access tokens of the principal company', function () { + SocketAuthFixtures::database(); + + $user = socket_channel_principal(); + $sandbox = socket_channel_principal(['env' => 'test']); + + expect(CoreChannelResolvers::api($user, 'cred-a'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, 'cred-b'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, 'cred-missing'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, 'cred-a-test'))->toBeTrue() + ->and(CoreChannelResolvers::api($sandbox, 'cred-a-test'))->toBeTrue() + ->and(CoreChannelResolvers::api($sandbox, 'cred-a'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, '1'))->toBeTrue() + ->and(CoreChannelResolvers::api($user, '2'))->toBeFalse() + ->and(CoreChannelResolvers::api($user, '99'))->toBeFalse() + ->and(CoreChannelResolvers::api(socket_channel_driver(), 'cred-a'))->toBeFalse(); +}); + +test('test and install channels follow their owner and company', function () { + SocketAuthFixtures::container(); + + $user = socket_channel_principal(); + + expect(CoreChannelResolvers::test($user, 'user-a1'))->toBeTrue() + ->and(CoreChannelResolvers::test($user, 'user-b1'))->toBeFalse() + ->and(CoreChannelResolvers::test(socket_channel_principal(['adm' => true]), 'user-b1'))->toBeTrue() + ->and(CoreChannelResolvers::install($user, 'company-a.fleetops'))->toBeTrue() + ->and(CoreChannelResolvers::install($user, 'company-b.fleetops'))->toBeFalse() + ->and(CoreChannelResolvers::install(socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a']), 'company-a.fleetops'))->toBeTrue() + ->and(CoreChannelResolvers::install(socket_channel_driver(), 'company-a.fleetops'))->toBeFalse() + ->and(CoreChannelResolvers::install(socket_channel_principal(['cid' => null]), 'company-a.fleetops'))->toBeFalse(); +}); + +test('company principals are denied every core channel of another company', function () { + SocketAuthFixtures::database(); + + $authorizer = socket_channel_core_authorizer(); + $user = socket_channel_principal(); + $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); + + foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1'] as $channel) { + expect($authorizer->authorize($user, $channel)->allow)->toBeTrue(); + } + + foreach (['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops'] as $channel) { + expect($authorizer->authorize($user, $channel)->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($authorizer->authorize($api, $channel)->allow)->toBeFalse(); + } +}); + +test('drivers reach only the chats they take part in', function () { + SocketAuthFixtures::database(); + + $authorizer = socket_channel_core_authorizer(); + $driver = socket_channel_driver(); + + foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a', 'user.user-a1', 'driver.driver-1'] as $channel) { + expect($authorizer->authorize($driver, $channel)->allow)->toBeTrue(); + } + + foreach (['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a'] as $channel) { + expect($authorizer->authorize($driver, $channel)->allow)->toBeFalse(); + } + + expect($authorizer->authorize(socket_channel_driver(['ids' => ['driver-1']]), 'chat.chat_aaa')->allow)->toBeFalse() + ->and($authorizer->authorize(socket_channel_driver(['ids' => []]), 'chat.chat_aaa')->allow)->toBeFalse() + ->and(CoreChannelResolvers::isChatParticipant($driver, null))->toBeFalse(); +}); diff --git a/tests/Unit/Support/SocketTokenTest.php b/tests/Unit/Support/SocketTokenTest.php new file mode 100644 index 00000000..ceddd783 --- /dev/null +++ b/tests/Unit/Support/SocketTokenTest.php @@ -0,0 +1,381 @@ +toBeNull() + ->and(SocketToken::enabled())->toBeFalse(); + + config(['broadcasting.connections.socketcluster.auth_key' => 'too-short-for-hs256']); + + expect(SocketToken::key())->toBeNull() + ->and(SocketToken::enabled())->toBeFalse(); + + config(['broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); + + expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) + ->and(SocketToken::enabled())->toBeTrue(); +}); + +test('issuing a socket token requires the feature to be configured', function () { + SocketAuthFixtures::container(null); + + SocketToken::issue(SocketPrincipal::system()); +})->throws(RuntimeException::class, 'Socket authentication is not configured.'); + +test('issued tokens carry the contract header and claims and verify back to the principal', function () { + SocketAuthFixtures::container(); + + $minted = SocketToken::issue(new SocketPrincipal( + kind: 'user', + sub: 'user-a1', + cid: 'company-a', + cpid: 'company_aaa', + ids: ['user-a1', 'user_a1'], + adm: true + )); + $payload = SocketAuthFixtures::payload($minted['token']); + $verified = SocketToken::verify($minted['token']); + + expect(SocketAuthFixtures::header($minted['token']))->toEqual(['alg' => 'HS256', 'typ' => 'JWT']) + ->and($minted['expires_in'])->toBe(900) + ->and($minted['expires_at'])->toBe((new DateTimeImmutable('@' . (SocketAuthFixtures::NOW + 900)))->format(DATE_ATOM)) + ->and($payload)->toMatchArray([ + 'iss' => 'fleetbase-api', + 'aud' => 'fleetbase-socket', + 'iat' => SocketAuthFixtures::NOW, + 'nbf' => SocketAuthFixtures::NOW, + 'exp' => SocketAuthFixtures::NOW + 900, + 'sub' => 'user-a1', + 'kind' => 'user', + 'cid' => 'company-a', + 'cpid' => 'company_aaa', + 'env' => 'live', + 'ids' => ['user-a1', 'user_a1'], + 'adm' => true, + ]) + ->and($payload)->not->toHaveKey('scp') + ->and($payload)->not->toHaveKey('sid') + ->and($payload['jti'])->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/') + ->and($verified)->toBeInstanceOf(SocketPrincipal::class) + ->and($verified->kind)->toBe('user') + ->and($verified->sub)->toBe('user-a1') + ->and($verified->cid)->toBe('company-a') + ->and($verified->cpid)->toBe('company_aaa') + ->and($verified->ids)->toBe(['user-a1', 'user_a1']) + ->and($verified->adm)->toBeTrue() + ->and($verified->scp)->toBeNull() + ->and($verified->jti)->toBe($payload['jti']) + ->and($verified->exp)->toBe(SocketAuthFixtures::NOW + 900); +}); + +test('token lifetimes follow the configured ttl per kind and stay within the socket server limit', function () { + SocketAuthFixtures::container(); + + expect(SocketToken::defaultTtl('user'))->toBe(900) + ->and(SocketToken::defaultTtl('tracking'))->toBe(1800) + ->and(SocketToken::defaultTtl('system'))->toBe(300) + ->and(SocketToken::issue(SocketPrincipal::system())['expires_in'])->toBe(300) + ->and(SocketToken::issue(SocketPrincipal::system(), 99999)['expires_in'])->toBe(3600) + ->and(SocketToken::issue(SocketPrincipal::system(), 0)['expires_in'])->toBe(1); + + config(['broadcasting.connections.socketcluster.token_ttl' => 120]); + + expect(SocketToken::defaultTtl('api'))->toBe(120); + + config(['broadcasting.connections.socketcluster.token_ttl' => 0]); + + expect(SocketToken::defaultTtl('driver'))->toBe(900); +}); + +test('verification rejects tokens that are not ours or no longer valid', function (array $header, array $claims, ?string $key) { + SocketAuthFixtures::container(); + + expect(SocketToken::verify(SocketAuthFixtures::jwt($header, $claims, $key)))->toBeNull(); +})->with([ + 'wrong key' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::OTHER_KEY], + 'alg none' => [['alg' => 'none', 'typ' => 'JWT'], SocketAuthFixtures::claims(), null], + 'asymmetric alg' => [['alg' => 'RS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::KEY], + 'missing audience' => [['alg' => 'HS256', 'typ' => 'JWT'], array_diff_key(SocketAuthFixtures::claims(), ['aud' => true]), SocketAuthFixtures::KEY], + 'wrong audience' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['aud' => 'someone-else']), SocketAuthFixtures::KEY], + 'wrong issuer' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['iss' => 'someone-else']), SocketAuthFixtures::KEY], + 'missing expiry' => [['alg' => 'HS256', 'typ' => 'JWT'], array_diff_key(SocketAuthFixtures::claims(), ['exp' => true]), SocketAuthFixtures::KEY], + 'expired' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['exp' => SocketAuthFixtures::NOW - 1]), SocketAuthFixtures::KEY], + 'not yet valid' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['nbf' => SocketAuthFixtures::NOW + 60]), SocketAuthFixtures::KEY], + 'unknown kind claim' => [['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(['kind' => 'robot']), SocketAuthFixtures::KEY], +]); + +test('verification accepts a well formed token and rejects garbage or a disabled feature', function () { + SocketAuthFixtures::container(); + + $token = SocketAuthFixtures::jwt(['alg' => 'HS256', 'typ' => 'JWT'], SocketAuthFixtures::claims(), SocketAuthFixtures::KEY); + + expect(SocketToken::verify($token))->toBeInstanceOf(SocketPrincipal::class) + ->and(SocketToken::verify($token)->jti)->toBe('jti-handmade') + ->and(SocketToken::verify(''))->toBeNull() + ->and(SocketToken::verify('not-a-jwt'))->toBeNull(); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketToken::verify($token))->toBeNull(); +}); + +test('verification rejects an issued token once it has expired', function () { + SocketAuthFixtures::container(); + + $token = SocketToken::issue(SocketPrincipal::system(), 60)['token']; + + expect(SocketToken::verify($token))->toBeInstanceOf(SocketPrincipal::class); + + Carbon::setTestNow(Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW + 61)); + + expect(SocketToken::verify($token))->toBeNull(); +}); + +test('system tokens are short lived and carry no company', function () { + SocketAuthFixtures::container(); + + $token = SocketToken::system(); + $principal = SocketToken::verify($token); + + expect($principal->kind)->toBe('system') + ->and($principal->isSystem())->toBeTrue() + ->and($principal->cid)->toBeNull() + ->and($principal->exp)->toBe(SocketAuthFixtures::NOW + 300) + ->and(SocketAuthFixtures::payload($token))->not->toHaveKey('cid') + ->and(SocketAuthFixtures::payload($token))->not->toHaveKey('cpid'); +}); + +test('base32 encoding follows rfc 4648 in lowercase without padding', function () { + expect(SocketToken::base32('f'))->toBe('my') + ->and(SocketToken::base32('foobar'))->toBe('mzxw6ytboi'); +}); + +test('tracking ids are the truncated base32 hmac of the tracking scope under the tracking key', function () { + SocketAuthFixtures::container(); + + // Computed independently: base32(HMAC-SHA256(hex(HMAC-SHA256(KEY, "fleetbase-socket:tracking")), msg))[:26]. + expect(SocketToken::trackingId('order-a', 'contact', 'contact-1'))->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and(SocketToken::trackingId('order-a', 'contact', 'contact-2'))->not->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and(SocketToken::trackingId('order-a', 'vendor', 'contact-1'))->toMatch('/^[a-z2-7]{26}$/'); +}); + +test('tracking tokens may only follow their own tracking channel', function () { + SocketAuthFixtures::container(); + + $minted = SocketToken::forTracking(new SocketTokenTrackingScope('order-a', 'contact', 'contact-1', 'company-a')); + $principal = SocketToken::verify($minted['token']); + + expect($minted['expires_in'])->toBe(1800) + ->and($principal->kind)->toBe('tracking') + ->and($principal->sub)->toBe('r4pb2bnb4fi2ekuheuv2qonlpp') + ->and($principal->cid)->toBe('company-a') + ->and($principal->scp)->toBe(['tracking.r4pb2bnb4fi2ekuheuv2qonlpp']); +}); + +test('tracking tokens take the company from the order when the scope does not carry it', function () { + SocketAuthFixtures::database(); + + $known = SocketToken::verify(SocketToken::forTracking(new SocketTokenTrackingScope('order-a', 'contact', 'contact-1'))['token']); + $unknown = SocketToken::verify(SocketToken::forTracking(new SocketTokenTrackingScope('order-missing', 'contact', 'contact-1'))['token']); + + expect($known->cid)->toBe('company-a') + ->and($unknown->cid)->toBeNull() + ->and($unknown->scp)->toHaveCount(1); +}); + +test('socket request signatures use per-purpose keys derived from the auth key', function () { + SocketAuthFixtures::container(); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + $timestamp = (string) SocketAuthFixtures::NOW; + $body = '{"channels":["order.x"],"data":{}}'; + $signature = hash_hmac('sha256', $timestamp . '.' . $body, $publishKey); + $stale = (string) (SocketAuthFixtures::NOW - 61); + $edge = (string) (SocketAuthFixtures::NOW - 60); + $future = (string) (SocketAuthFixtures::NOW + 61); + + expect(SocketSignature::deriveKey(SocketSignature::PUBLISH))->toBe($publishKey) + ->and(SocketSignature::deriveKey(SocketSignature::AUTHORIZE))->toBe(hash_hmac('sha256', 'fleetbase-socket:authorize', SocketAuthFixtures::KEY)) + ->and(SocketSignature::headers(SocketSignature::PUBLISH, $body))->toBe([ + 'X-Fleetbase-Timestamp' => $timestamp, + 'X-Fleetbase-Signature' => $signature, + ]) + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body))->toBeTrue() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, strtoupper($signature), $body))->toBeTrue() + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $timestamp, $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body . ' '))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, null, $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, 'yesterday', $signature, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, null, $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, '', $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $stale, SocketSignature::sign(SocketSignature::PUBLISH, $stale, $body), $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $future, SocketSignature::sign(SocketSignature::PUBLISH, $future, $body), $body))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::PUBLISH, $edge, SocketSignature::sign(SocketSignature::PUBLISH, $edge, $body), $body))->toBeTrue(); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketSignature::verify(SocketSignature::PUBLISH, $timestamp, $signature, $body))->toBeFalse(); +}); + +test('deriving a signing key requires the feature to be configured', function () { + SocketAuthFixtures::container(null); + + SocketSignature::deriveKey(SocketSignature::PUBLISH); +})->throws(RuntimeException::class, 'Socket authentication is not configured.'); + +test('socket principals reject unknown kinds, empty subjects and unknown environments', function (array $arguments) { + new SocketPrincipal(...$arguments); +})->throws(InvalidArgumentException::class)->with([ + 'unknown kind' => [['kind' => 'robot', 'sub' => 'someone']], + 'empty subject' => [['kind' => 'user', 'sub' => '']], + 'unknown environment' => [['kind' => 'user', 'sub' => 'someone', 'env' => 'staging']], +]); + +test('principals rebuild from claims and serialize back without unset claims', function () { + SocketAuthFixtures::container(); + + $principal = SocketPrincipal::fromClaims([ + 'kind' => 'customer', + 'sub' => 'contact-1', + 'cid' => 'company-a', + 'cpid' => '', + 'env' => 'test', + 'ids' => ['contact-1', '', 7, 'contact_1'], + 'adm' => 0, + 'scp' => 'storefront.store_1', + 'sid' => 'store-1', + 'jti' => 'jti-1', + 'exp' => new DateTimeImmutable('@' . (SocketAuthFixtures::NOW + 30)), + ]); + $minimal = SocketPrincipal::fromClaims(['kind' => 'api', 'sub' => 'cred-a']); + $changed = $principal->with(['env' => 'live', 'sid' => null]); + + expect($principal->cpid)->toBeNull() + ->and($principal->ids)->toBe(['contact-1', 'contact_1']) + ->and($principal->adm)->toBeFalse() + ->and($principal->scp)->toBe(['storefront.store_1']) + ->and($principal->exp)->toBe(SocketAuthFixtures::NOW + 30) + ->and($principal->secondsRemaining())->toBe(30) + ->and($principal->isCompanyScoped())->toBeFalse() + ->and($principal->isSystem())->toBeFalse() + ->and($principal->owns('contact_1'))->toBeTrue() + ->and($principal->owns('contact-2'))->toBeFalse() + ->and($principal->owns(''))->toBeFalse() + ->and($principal->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'contact-1', + 'cid' => 'company-a', + 'env' => 'test', + 'ids' => ['contact-1', 'contact_1'], + 'adm' => false, + 'scp' => ['storefront.store_1'], + 'sid' => 'store-1', + 'jti' => 'jti-1', + 'exp' => SocketAuthFixtures::NOW + 30, + ]) + ->and($minimal->env)->toBe('live') + ->and($minimal->ids)->toBe([]) + ->and($minimal->scp)->toBeNull() + ->and($minimal->exp)->toBeNull() + ->and($minimal->secondsRemaining())->toBeNull() + ->and($minimal->isCompanyScoped())->toBeTrue() + ->and(SocketPrincipal::fromClaims(['kind' => 'api', 'sub' => 'cred-a', 'exp' => (string) (SocketAuthFixtures::NOW + 5)])->exp)->toBe(SocketAuthFixtures::NOW + 5) + ->and($changed->kind)->toBe('customer') + ->and($changed->env)->toBe('live') + ->and($changed->sid)->toBeNull() + ->and(SocketPrincipal::system()->toClaims())->toBe([ + 'kind' => 'system', + 'sub' => 'system', + 'env' => 'live', + 'ids' => [], + 'adm' => false, + ]); +}); + +test('user principals take the company from the argument, then the session, then the user', function () { + SocketAuthFixtures::database(); + + $admin = SocketAuthFixtures::user(['type' => 'admin']); + $explicit = SocketPrincipal::forUser($admin, 'company-b'); + + session(['company' => 'company-b']); + $fromSession = SocketPrincipal::forUser(SocketAuthFixtures::user()); + session()->flush(); + + $fromUser = SocketPrincipal::forUser(SocketAuthFixtures::user()); + $unknown = SocketPrincipal::forUser(SocketAuthFixtures::user(['company_uuid' => 'company-missing', 'public_id' => null])); + $none = SocketPrincipal::forUser(SocketAuthFixtures::user(['company_uuid' => null])); + + expect($explicit->kind)->toBe('user') + ->and($explicit->sub)->toBe('user-a1') + ->and($explicit->cid)->toBe('company-b') + ->and($explicit->cpid)->toBe('company_bbb') + ->and($explicit->env)->toBe('live') + ->and($explicit->ids)->toBe(['user-a1', 'user_a1']) + ->and($explicit->adm)->toBeTrue() + ->and($fromSession->cid)->toBe('company-b') + ->and($fromSession->adm)->toBeFalse() + ->and($fromUser->cid)->toBe('company-a') + ->and($fromUser->cpid)->toBe('company_aaa') + ->and($unknown->cid)->toBe('company-missing') + ->and($unknown->cpid)->toBeNull() + ->and($unknown->ids)->toBe(['user-a1']) + ->and($none->cid)->toBeNull() + ->and($none->cpid)->toBeNull(); +}); + +test('api credential principals act in the credential environment', function () { + SocketAuthFixtures::database(); + + $live = SocketPrincipal::forApiCredential(ApiCredential::query()->find('cred-a')); + $test = SocketPrincipal::forApiCredential(ApiCredential::on('sandbox')->find('cred-a-test')); + + expect($live->kind)->toBe('api') + ->and($live->sub)->toBe('cred-a') + ->and($live->cid)->toBe('company-a') + ->and($live->cpid)->toBe('company_aaa') + ->and($live->env)->toBe('live') + ->and($live->ids)->toBe(['cred-a']) + ->and($test->sub)->toBe('cred-a-test') + ->and($test->env)->toBe('test') + ->and($test->cpid)->toBe('company_aaa'); +}); + +test('channel decisions serialize, rebuild from cache and cap their lifetime', function () { + $allowed = ChannelDecision::allowed('self'); + + expect($allowed->toArray())->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'self']) + ->and(ChannelDecision::denied('forbidden')->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($allowed->capTtl(42)->ttl)->toBe(42) + ->and($allowed->capTtl(0)->ttl)->toBe(1) + ->and($allowed->capTtl(900)->ttl)->toBe(300) + ->and(ChannelDecision::fromArray(['allow' => true, 'ttl' => 12, 'reason' => 'cached'])->toArray())->toBe(['allow' => true, 'ttl' => 12, 'reason' => 'cached']) + ->and(ChannelDecision::fromArray([])->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'denied']); +}); From 7e6f3c615acbd701177ab9b25ee422d70d772b05 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:44 +0800 Subject: [PATCH 02/10] feat(socket-auth): socket token and channel authorize endpoints - POST int/v1/socket/token (console session): a user token for the current company, in the sandbox environment when the console is in sandbox mode. - POST v1/socket/token (public API): an api token for an API credential; for a Sanctum user token, the principal a registered resolver claims, else a user token. - POST int/v1/socket/authorize: called by the socket server only, admitted by its signature (VerifySocketSignature) rather than a session; re-verifies the token and returns {allow, ttl, reason}. Exempt from the configured-instance check so an install page can follow the install channel before setup ends. - Every mint route answers 404 while SOCKETCLUSTER_AUTH_KEY is unset. --- src/Http/Controllers/SocketAuthController.php | 113 +++++++++++ .../Middleware/EnsureFleetbaseConfigured.php | 6 + src/Http/Middleware/VerifySocketSignature.php | 36 ++++ src/routes.php | 8 + tests/Unit/Http/MiddlewareContractsTest.php | 18 ++ tests/Unit/Http/SocketAuthControllerTest.php | 178 ++++++++++++++++++ tests/Unit/RoutesContractTest.php | 18 ++ 7 files changed, 377 insertions(+) create mode 100644 src/Http/Controllers/SocketAuthController.php create mode 100644 src/Http/Middleware/VerifySocketSignature.php create mode 100644 tests/Unit/Http/SocketAuthControllerTest.php diff --git a/src/Http/Controllers/SocketAuthController.php b/src/Http/Controllers/SocketAuthController.php new file mode 100644 index 00000000..82315838 --- /dev/null +++ b/src/Http/Controllers/SocketAuthController.php @@ -0,0 +1,113 @@ +user(); + + if (!$user instanceof User) { + return response()->json(['error' => 'Unauthenticated.'], 401); + } + + $principal = SocketPrincipal::forUser($user); + + // The console's sandbox toggle reads and writes the sandbox database. + if (Utils::isTrue($request->header('Access-Console-Sandbox'))) { + $principal = $principal->with(['env' => 'test']); + } + + return response()->json(SocketToken::issue($principal)); + } + + /** + * POST v1/socket/token: an api token for an API credential; for a Sanctum user token, + * the principal a registered resolver claims (a driver, for FleetOps) or else a user token. + */ + public function apiToken(Request $request) + { + if (!SocketToken::enabled()) { + return static::disabled(); + } + + $bearer = $request->bearerToken(); + $personalAccessToken = $bearer ? PersonalAccessToken::findToken($bearer) : null; + + if ($personalAccessToken !== null && $personalAccessToken->tokenable instanceof User) { + $user = $personalAccessToken->tokenable; + $principal = app(SocketChannelRegistry::class)->resolvePrincipal($request, $user) ?? SocketPrincipal::forUser($user, $user->company_uuid); + + return response()->json(SocketToken::issue($principal)); + } + + $credential = Auth::getApiKey(); + + if ($credential === null) { + return response()->json(['error' => 'Unauthenticated.'], 401); + } + + return response()->json(SocketToken::issue(SocketPrincipal::forApiCredential($credential))); + } + + /** + * A system token for a platform API caller. + */ + public function systemToken(Request $request) + { + if (!SocketToken::enabled()) { + return static::disabled(); + } + + return response()->json(SocketToken::issue(SocketPrincipal::system())); + } + + /** + * POST int/v1/socket/authorize: the socket server asks whether a token may subscribe to a channel. + * + * The token is verified here again; nothing the socket server derived from it is trusted. + */ + public function authorizeChannel(Request $request) + { + $token = $request->input('token'); + $channel = $request->input('channel'); + $hasToken = is_string($token) && $token !== ''; + $principal = $hasToken ? SocketToken::verify($token) : null; + $decision = app(ChannelAuthorizer::class)->authorize($principal, is_string($channel) ? $channel : ''); + + if ($hasToken && $principal === null && !$decision->allow) { + $decision = ChannelDecision::denied('invalid_token'); + } + + return response()->json($decision->toArray()); + } + + protected static function disabled() + { + return response()->json(['error' => 'Not Found'], 404); + } +} diff --git a/src/Http/Middleware/EnsureFleetbaseConfigured.php b/src/Http/Middleware/EnsureFleetbaseConfigured.php index c6b8a3bc..e24329c7 100644 --- a/src/Http/Middleware/EnsureFleetbaseConfigured.php +++ b/src/Http/Middleware/EnsureFleetbaseConfigured.php @@ -44,6 +44,12 @@ protected function shouldCheck(Request $request): bool return false; } + // The socket server asks this endpoint whether an anonymous install page may follow + // the install channel, which is exactly the case before setup has finished. + if ($request->is('int/v1/socket/authorize') || $request->is('*/int/v1/socket/authorize')) { + return false; + } + return $request->is('int/*') || $request->is('*/int/*') || $request->is('v1/*') diff --git a/src/Http/Middleware/VerifySocketSignature.php b/src/Http/Middleware/VerifySocketSignature.php new file mode 100644 index 00000000..ff7ea394 --- /dev/null +++ b/src/Http/Middleware/VerifySocketSignature.php @@ -0,0 +1,36 @@ +json(['error' => 'Not Found'], 404); + } + + $valid = SocketSignature::verify( + SocketSignature::AUTHORIZE, + $request->header(SocketSignature::HEADER_TIMESTAMP), + $request->header(SocketSignature::HEADER_SIGNATURE), + $request->getContent() + ); + + if (!$valid) { + return response()->json(['error' => 'invalid_signature'], 401); + } + + return $next($request); + } +} diff --git a/src/routes.php b/src/routes.php index fe76c767..ca1b9638 100644 --- a/src/routes.php +++ b/src/routes.php @@ -41,6 +41,8 @@ function ($router) { ->namespace('Api\v1') ->middleware(['fleetbase.api']) ->group(function ($router) { + // Realtime socket token for an API credential or a Sanctum user token. + $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'apiToken']); $router->group( ['prefix' => 'organizations'], function ($router) { @@ -163,6 +165,10 @@ function ($router) { $router->get('branding', 'SettingController@getBrandingSettings'); } ); + // Called by the socket server only: authenticated by its request signature, + // never by a session or user token. + $router->post('socket/authorize', [Fleetbase\Http\Controllers\SocketAuthController::class, 'authorizeChannel']) + ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class); $router->group( ['prefix' => 'two-fa', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { @@ -176,6 +182,8 @@ function ($router) { $router->group( ['middleware' => ['fleetbase.protected']], function ($router) { + // Realtime socket token for the signed-in console user. + $router->post('socket/token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'token']); $router->group( ['prefix' => 'lookup'], function ($router) { diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 952a0f49..111efe5f 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -536,6 +536,24 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ->and($options->getData(true))->toBe(['ok' => true]); }); + test('ensure fleetbase configured lets the socket server authorize the install channel before setup', function () { + middleware_contracts_fixture(); + + $middleware = middleware_contracts_configured_middleware(null, [], true); + $internal = $middleware->handle( + middleware_contracts_request('/int/v1/socket/authorize', 'int/v1/socket/authorize'), + fn () => new JsonResponse(['authorized' => true]) + ); + $prefixed = $middleware->handle( + middleware_contracts_request('/api/int/v1/socket/authorize', 'api/int/v1/socket/authorize'), + fn () => new JsonResponse(['authorized' => true]) + ); + + expect($internal->getStatusCode())->toBe(200) + ->and($internal->getData(true))->toBe(['authorized' => true]) + ->and($prefixed->getStatusCode())->toBe(200); + }); + test('ensure fleetbase configured returns setup error when database or core tables are missing', function () { middleware_contracts_fixture(); diff --git a/tests/Unit/Http/SocketAuthControllerTest.php b/tests/Unit/Http/SocketAuthControllerTest.php new file mode 100644 index 00000000..19cdb530 --- /dev/null +++ b/tests/Unit/Http/SocketAuthControllerTest.php @@ -0,0 +1,178 @@ +instance(SocketChannelRegistry::class, $registry); + app()->instance(ChannelAuthorizer::class, new ChannelAuthorizer($registry)); + + return $registry; +} + +function socket_auth_controller_request(string $uri, array $server = [], ?string $content = null): Request +{ + return Request::create($uri, 'POST', [], [], [], array_merge(['CONTENT_TYPE' => 'application/json'], $server), $content); +} + +function socket_auth_controller_principal(JsonResponse $response): ?SocketPrincipal +{ + return SocketToken::verify($response->getData(true)['token']); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('every token route answers 404 while socket authentication is not configured', function () { + SocketAuthFixtures::container(null); + + $controller = new SocketAuthController(); + $request = socket_auth_controller_request('/int/v1/socket/token'); + + foreach ([$controller->token($request), $controller->apiToken($request), $controller->systemToken($request)] as $response) { + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not Found']); + } +}); + +test('console sessions receive a user token for their company and environment', function () { + SocketAuthFixtures::database(); + + $controller = new SocketAuthController(); + $unauthenticated = $controller->token(socket_auth_controller_request('/int/v1/socket/token')); + + $request = socket_auth_controller_request('/int/v1/socket/token'); + $request->setUserResolver(fn () => User::query()->find('user-a1')); + $response = $controller->token($request); + + $sandboxRequest = socket_auth_controller_request('/int/v1/socket/token', ['HTTP_ACCESS_CONSOLE_SANDBOX' => 'true']); + $sandboxRequest->setUserResolver(fn () => User::query()->find('user-a1')); + $sandbox = socket_auth_controller_principal($controller->token($sandboxRequest)); + + $principal = socket_auth_controller_principal($response); + + expect($unauthenticated->getStatusCode())->toBe(401) + ->and($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toHaveKeys(['token', 'expires_in', 'expires_at']) + ->and($response->getData(true)['expires_in'])->toBe(900) + ->and($principal->kind)->toBe('user') + ->and($principal->sub)->toBe('user-a1') + ->and($principal->cid)->toBe('company-a') + ->and($principal->cpid)->toBe('company_aaa') + ->and($principal->env)->toBe('live') + ->and($sandbox->env)->toBe('test'); +}); + +test('api clients receive an api, user or registered principal token', function () { + SocketAuthFixtures::database(); + + $registry = socket_auth_controller_registry(); + $controller = new SocketAuthController(); + $userToken = socket_auth_controller_request('/v1/socket/token', ['HTTP_AUTHORIZATION' => 'Bearer plain-token-a1']); + $user = socket_auth_controller_principal($controller->apiToken($userToken)); + + $registry->registerPrincipalResolver(function (Request $request, User $user) { + return new SocketPrincipal(kind: 'driver', sub: 'driver-1', cid: $user->company_uuid, ids: ['driver-1', $user->uuid]); + }); + $driver = socket_auth_controller_principal($controller->apiToken($userToken)); + + session(['api_credential' => 'cred-a']); + $credential = socket_auth_controller_principal($controller->apiToken(socket_auth_controller_request('/v1/socket/token', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_a']))); + session()->flush(); + + $anonymous = $controller->apiToken(socket_auth_controller_request('/v1/socket/token')); + + expect($user->kind)->toBe('user') + ->and($user->sub)->toBe('user-a1') + ->and($user->cid)->toBe('company-a') + ->and($driver->kind)->toBe('driver') + ->and($driver->ids)->toBe(['driver-1', 'user-a1']) + ->and($credential->kind)->toBe('api') + ->and($credential->sub)->toBe('cred-a') + ->and($credential->env)->toBe('live') + ->and($anonymous->getStatusCode())->toBe(401); +}); + +test('platform callers receive a short lived system token', function () { + SocketAuthFixtures::container(); + + $response = (new SocketAuthController())->systemToken(socket_auth_controller_request('/v1/socket/token')); + $principal = socket_auth_controller_principal($response); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true)['expires_in'])->toBe(300) + ->and($principal->isSystem())->toBeTrue(); +}); + +test('the authorize endpoint re-verifies the token and answers with a cacheable decision', function () { + SocketAuthFixtures::database(); + socket_auth_controller_registry(); + + $controller = new SocketAuthController(); + $token = SocketToken::issue(SocketPrincipal::forUser(User::query()->find('user-a1')))['token']; + $ask = function (array $body) use ($controller) { + return $controller->authorizeChannel(socket_auth_controller_request('/int/v1/socket/authorize', [], json_encode($body)))->getData(true); + }; + + expect($ask(['token' => $token, 'channel' => 'chat.chat_aaa']))->toBe(['allow' => true, 'ttl' => 300, 'reason' => 'resolver']) + ->and($ask(['token' => $token, 'channel' => 'chat.chat_bbb']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) + ->and($ask(['token' => $token . 'x', 'channel' => 'chat.chat_aaa']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_token']) + ->and($ask(['token' => null, 'channel' => 'company.company_aaa']))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'no_token']) + ->and($ask(['token' => $token, 'channel' => ['not', 'a', 'string']]))->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'invalid_channel']); +}); + +test('the authorize endpoint only admits requests signed with the authorize key', function () { + SocketAuthFixtures::container(); + + $middleware = new VerifySocketSignature(); + $body = '{"token":null,"channel":"fleetbase.install"}'; + $now = (string) SocketAuthFixtures::NOW; + $stale = (string) (SocketAuthFixtures::NOW - 120); + $next = function () { + return new JsonResponse(['passed' => true]); + }; + $send = function (?string $timestamp, ?string $signature) use ($middleware, $body, $next) { + $headers = array_filter([ + 'HTTP_X_FLEETBASE_TIMESTAMP' => $timestamp, + 'HTTP_X_FLEETBASE_SIGNATURE' => $signature, + ]); + + return $middleware->handle(socket_auth_controller_request('/int/v1/socket/authorize', $headers, $body), $next); + }; + + $signature = SocketSignature::sign(SocketSignature::AUTHORIZE, $now, $body); + $good = $send($now, $signature); + $rejected = [ + $send($now, SocketSignature::sign(SocketSignature::PUBLISH, $now, $body)), + $send($stale, SocketSignature::sign(SocketSignature::AUTHORIZE, $stale, $body)), + $send($now, str_repeat('0', 64)), + $send(null, null), + ]; + + expect($good->getStatusCode())->toBe(200) + ->and($good->getData(true))->toBe(['passed' => true]); + + foreach ($rejected as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'invalid_signature']); + } + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect($send($now, $signature)->getStatusCode())->toBe(404); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index f7e8fe2e..569b6550 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -365,6 +365,24 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->toBe('Fleetbase\Http\Controllers\Internal\v1\NotificationController@registry'); }); + test('route file exposes socket token minting per client type and a signature-only authorize endpoint', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\SocketAuthController'; + + $consoleToken = routes_contract_find($routes, 'POST', 'int/v1/socket/token'); + $apiToken = routes_contract_find($routes, 'POST', 'v1/socket/token'); + $authorize = routes_contract_find($routes, 'POST', 'int/v1/socket/authorize'); + + expect($consoleToken['action'])->toBe($controller . '@token') + ->and($consoleToken['middleware'])->toContain('fleetbase.protected') + ->and($apiToken['action'])->toBe($controller . '@apiToken') + ->and($apiToken['middleware'])->toContain('fleetbase.api') + ->and($apiToken['middleware'])->not->toContain('fleetbase.platform-api') + // Only the socket server calls this; its signature is the whole of its authentication. + ->and($authorize['action'])->toBe($controller . '@authorizeChannel') + ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); + }); + test('route file exposes api rate limit administration as protected routes', function () { $routes = routes_contract_rows(routes_contract_router()); $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\RateLimitController'; From 5b13f216c415484f46060af8cdb58b414f3e7035 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 03/10] feat(socket-auth): publish broadcasts with one signed HTTP request When SOCKETCLUSTER_AUTH_KEY is set, the broadcaster and SocketClusterService::publish()/send() post every channel of a broadcast in a single request to {SOCKETCLUSTER_PUBLISH_URL}/publish, signed with the derived publish key and short timeouts. Without the key the websocket publisher is used as before. Channels ending in "." (an empty suffix, e.g. a session read in a queue worker) and names the socket server would reject are dropped before publishing. --- .../SocketClusterBroadcaster.php | 7 +- .../SocketCluster/SocketClusterService.php | 100 ++++++++++++++ .../Support/SocketClusterHttpPublishTest.php | 130 ++++++++++++++++++ 3 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Support/SocketClusterHttpPublishTest.php diff --git a/src/Support/SocketCluster/SocketClusterBroadcaster.php b/src/Support/SocketCluster/SocketClusterBroadcaster.php index fa6d6526..fb621e54 100644 --- a/src/Support/SocketCluster/SocketClusterBroadcaster.php +++ b/src/Support/SocketCluster/SocketClusterBroadcaster.php @@ -42,14 +42,15 @@ public function validAuthenticationResponse($request, $result) /** * Broadcast. * + * Channels with an empty suffix are dropped; when signed publishing is configured every + * channel goes out in a single request. + * * @param string $event * * @return void */ public function broadcast(array $channels, $event, array $payload = []) { - foreach ($channels as $channel) { - $this->socketcluster->send($channel, $payload); - } + $this->socketcluster->sendMany($channels, $payload); } } diff --git a/src/Support/SocketCluster/SocketClusterService.php b/src/Support/SocketCluster/SocketClusterService.php index a3596821..af83c187 100644 --- a/src/Support/SocketCluster/SocketClusterService.php +++ b/src/Support/SocketCluster/SocketClusterService.php @@ -2,12 +2,17 @@ namespace Fleetbase\Support\SocketCluster; +use Illuminate\Support\Facades\Http; use WebSocket\Client; /** * Class SocketClusterService. * * Service class for managing SocketCluster connections and messages. + * + * With SOCKETCLUSTER_AUTH_KEY configured, messages are published with one signed HTTP + * request to the socket server's internal publish endpoint. Without it they are sent over + * the websocket as before. */ class SocketClusterService { @@ -152,6 +157,10 @@ public static function publish($channel, array $data = [], $options = []): bool */ public function send($channel, array $data = []): bool { + if (static::publishesOverHttp()) { + return $this->sendMany([$channel], $data); + } + $cid = rand(); $message = new SocketClusterMessage($channel, $data, $cid); $this->sent = false; @@ -173,6 +182,97 @@ public function send($channel, array $data = []): bool return $this->sent; } + /** + * Sends one message to several channels. + * + * Channels with an empty suffix (for example "company." from a session read in a queue + * worker) are dropped. Over HTTP all channels go in a single request; over the websocket + * each channel is sent in turn. Returns true when every send succeeded. + */ + public function sendMany(array $channels, array $data = []): bool + { + $channels = static::filterChannels($channels); + + if ($channels === []) { + return true; + } + + if (static::publishesOverHttp()) { + return $this->publishOverHttp($channels, $data); + } + + $sent = true; + + foreach ($channels as $channel) { + $sent = $this->send($channel, $data) && $sent; + } + + return $sent; + } + + /** + * Normalizes channels to unique names, dropping those ending in "." and any the socket + * server would reject (empty, longer than 255 characters or containing whitespace). + */ + public static function filterChannels(array $channels): array + { + $names = array_map(fn ($channel) => trim((string) $channel), $channels); + + return array_values(array_unique(array_filter($names, fn ($name) => ChannelAuthorizer::isValidChannel($name) && !str_ends_with($name, '.')))); + } + + /** + * Whether messages are published over the signed HTTP endpoint rather than the websocket. + */ + public static function publishesOverHttp(): bool + { + return SocketToken::enabled(); + } + + /** + * The socket server's internal publish endpoint. + */ + public static function publishUrl(): string + { + $base = config('broadcasting.connections.socketcluster.publish_url'); + + if (!is_string($base) || $base === '') { + $base = 'http://' . config('broadcasting.connections.socketcluster.options.host', 'socket') . ':8001'; + } + + return rtrim($base, '/') . '/publish'; + } + + /** + * Publishes to all channels with one request signed by the derived publish key. + */ + protected function publishOverHttp(array $channels, array $data): bool + { + $this->sent = false; + $this->error = null; + + try { + $body = json_encode(['channels' => $channels, 'data' => $data === [] ? new \stdClass() : $data], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $response = Http::withHeaders(SocketSignature::headers(SocketSignature::PUBLISH, $body)) + ->withBody($body, 'application/json') + ->acceptJson() + ->connectTimeout(2) + ->timeout(3) + ->post(static::publishUrl()); + + $this->response = $response->body(); + $this->sent = $response->successful(); + + if (!$this->sent) { + $this->error = 'Socket publish failed with HTTP status ' . $response->status() . '.'; + } + } catch (\Throwable $e) { + $this->error = $e->getMessage(); + } + + return $this->sent; + } + /** * Sends a handshake message to the SocketCluster server. * diff --git a/tests/Unit/Support/SocketClusterHttpPublishTest.php b/tests/Unit/Support/SocketClusterHttpPublishTest.php new file mode 100644 index 00000000..a6584276 --- /dev/null +++ b/tests/Unit/Support/SocketClusterHttpPublishTest.php @@ -0,0 +1,130 @@ +sentMessages[] = [$channel, $data]; + + return true; + } +} + +function socket_cluster_http_service(): SocketClusterService +{ + return new SocketClusterService(['secure' => false, 'host' => 'socket.test', 'port' => 8000, 'path' => '/socketcluster/']); +} + +afterEach(function () { + SocketAuthFixtures::reset(); +}); + +test('broadcasts go out as one signed publish request without empty-suffix channels', function () { + SocketAuthFixtures::container(); + Http::fake(['*' => Http::response(['published' => 2], 202)]); + + $service = socket_cluster_http_service(); + (new SocketClusterBroadcaster($service))->broadcast(['order.order_1', 'company.', 'api.', 'order.order_1', new Channel('company.company_aaa'), ''], 'order.updated', ['id' => 'order_1']); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + + Http::assertSentCount(1); + Http::assertSent(function (HttpRequest $request) use ($publishKey) { + $timestamp = $request->header('X-Fleetbase-Timestamp')[0]; + + return $request->url() === 'http://socket.test:8001/publish' + && $request->method() === 'POST' + && $request->body() === '{"channels":["order.order_1","company.company_aaa"],"data":{"id":"order_1"}}' + && $timestamp === (string) SocketAuthFixtures::NOW + && $request->header('X-Fleetbase-Signature')[0] === hash_hmac('sha256', $timestamp . '.' . $request->body(), $publishKey); + }); + + expect($service->response())->toBe('{"published":2}') + ->and($service->error())->toBeNull(); +}); + +test('the static publish api and single sends use the signed endpoint when configured', function () { + SocketAuthFixtures::container(SocketAuthFixtures::KEY, [ + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001/', + ]); + Http::fake(['*' => Http::response(['published' => 1], 202)]); + + expect(SocketClusterService::publish('company.company_aaa', ['event' => 'updated']))->toBeTrue() + ->and(socket_cluster_http_service()->send('chat.chat_aaa'))->toBeTrue() + ->and(socket_cluster_http_service()->send('company.'))->toBeTrue(); + + Http::assertSentCount(2); + Http::assertSent(fn (HttpRequest $request) => $request->body() === '{"channels":["company.company_aaa"],"data":{"event":"updated"}}'); + Http::assertSent(fn (HttpRequest $request) => $request->body() === '{"channels":["chat.chat_aaa"],"data":{}}'); +}); + +test('failed signed publishes report the error without throwing', function () { + SocketAuthFixtures::container(); + Http::fake(['*' => Http::response('unauthorized', 401)]); + + $rejected = socket_cluster_http_service(); + + expect($rejected->sendMany(['order.order_1'], ['id' => 'order_1']))->toBeFalse() + ->and($rejected->error())->toBe('Socket publish failed with HTTP status 401.') + ->and($rejected->response())->toBe('unauthorized'); + + // A fresh factory: stubs accumulate, so the 401 stub above would otherwise still match first. + Http::swap(new HttpFactory()); + Http::fake(function () { + throw new RuntimeException('socket server unreachable'); + }); + + $unreachable = socket_cluster_http_service(); + + expect($unreachable->sendMany(['order.order_1']))->toBeFalse() + ->and($unreachable->error())->not->toBeNull(); +}); + +test('without an auth key broadcasts keep using the websocket publisher per channel', function () { + SocketAuthFixtures::container(null); + Http::fake(); + + $service = new SocketClusterHttpPublishWebsocketRecorder(); + (new SocketClusterBroadcaster($service))->broadcast(['company.company_aaa', 'api.', 'user.user_a1'], 'user.updated', ['id' => 'user_a1']); + + expect($service->sentMessages)->toBe([ + ['company.company_aaa', ['id' => 'user_a1']], + ['user.user_a1', ['id' => 'user_a1']], + ]) + ->and($service->sendMany(['company.', ' ']))->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse(); + + Http::assertNothingSent(); +}); + +test('publish urls come from config with a fallback to the socket host internal port', function () { + SocketAuthFixtures::container(); + + expect(SocketClusterService::publishUrl())->toBe('http://socket.test:8001/publish') + ->and(SocketClusterService::filterChannels(['a.b', new Channel('c.d'), 'a.b', 'e.', ' ', 'f g', str_repeat('h', 256)]))->toBe(['a.b', 'c.d']); + + config(['broadcasting.connections.socketcluster.publish_url' => '']); + + expect(SocketClusterService::publishUrl())->toBe('http://socket.test:8001/publish'); + + config([ + 'broadcasting.connections.socketcluster.publish_url' => null, + 'broadcasting.connections.socketcluster.options.host' => 'realtime.internal', + ]); + + expect(SocketClusterService::publishUrl())->toBe('http://realtime.internal:8001/publish'); +}); From 819aa3812dd50d5574c53a9b1bc9e54959306e15 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 04/10] fix(socket-auth): admin socket test publishes only to the admin's own channel The SocketCluster settings test ignored nothing: any admin could publish an arbitrary payload to any channel. It now always publishes to test.{current user uuid} and returns that channel so the console can subscribe to it. --- .../Internal/v1/SettingController.php | 16 ++++++++++- .../SettingControllerExternalProbesTest.php | 28 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index ac994e1c..df5a00e4 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1040,14 +1040,28 @@ public function testSentryConfig(AdminRequest $request) /** * Test SocketCluster Configuration. * + * Publishes only to the signed-in user's own `test.{user uuid}` channel; any channel in the + * request is ignored. The channel used is returned so the console can subscribe to it. + * * @param Request $request the incoming HTTP request containing the authenticated user * * @return \Illuminate\Http\JsonResponse returns a JSON response with a success message and HTTP status 200 */ public function testSocketcluster(AdminRequest $request) { + $userUuid = session('user'); + + if (!is_string($userUuid) || $userUuid === '') { + return response()->json([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, + 'response' => null, + ]); + } + // Get the channel to publish to - $channel = $request->input('channel', 'test'); + $channel = 'test.' . $userUuid; $message = 'Socket broadcasted message successfully.'; $status = 'success'; $sent = false; diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index 4f6c04ee..f3948b38 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -28,7 +28,8 @@ function setting_controller_external_probe_fixtures(array $config = []): void 'token' => 'existing-token', 'from' => '+15555550100', ], - 'broadcasting.connections.socketcluster.options' => [ + 'broadcasting.connections.socketcluster.auth_key' => null, + 'broadcasting.connections.socketcluster.options' => [ 'secure' => false, 'host' => '127.0.0.1', 'port' => 9, @@ -170,16 +171,37 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ test('test socketcluster returns stable json when the configured socket cannot send', function () { setting_controller_external_probe_fixtures(); + session(['user' => 'user-probe']); + // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ - 'channel' => 'settings-probe', + 'channel' => 'company.someone-else', ])); + session()->flush(); + expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', 'message' => 'Socket broadcasted message successfully.', - 'channel' => 'settings-probe', + 'channel' => 'test.user-probe', + 'response' => null, + ]); +}); + +test('test socketcluster refuses to publish without a signed-in user', function () { + setting_controller_external_probe_fixtures(); + session()->flush(); + + $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'company.someone-else', + ])); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, 'response' => null, ]); }); From 3222eb41f3e261c1371e0c51147f1d4d6d799328 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:01:51 +0800 Subject: [PATCH 05/10] docs(socket-auth): document realtime channel authentication --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index c0adb0e6..de43866d 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,35 @@ Notes: - Transformers may return `MissingValue` / `MergeValue` objects; they are filtered like `when()` / `merge()` output. Keys excluded with `without()` stay excluded. - Re-registering a class replaces its options; `ResourceTransformerRegistry::forget()` and `reset()` remove registrations. - The registry is a container singleton (`app(ResourceTransformerRegistry::class)`); registrations happen at boot and are shared by every request in an Octane worker. + +## Realtime channel authentication + +Setting `SOCKETCLUSTER_AUTH_KEY` (a shared secret of at least 32 characters, also given to the socket server) turns on authenticated realtime channels. Without it nothing changes: no socket tokens are minted, the token routes answer 404, and broadcasts use the websocket publisher as before. + +| Variable | Default | Meaning | +|---|---|---| +| `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | +| `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | +| `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | + +Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel. + +A channel is authorized by the resolver registered for its prefix (the part before the first `.`); unknown prefixes are denied. Extensions register theirs from their service provider: + +```php +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; +use Fleetbase\Support\SocketCluster\SocketPrincipal; + +$registry = app(SocketChannelRegistry::class); + +// `order.{uuid|public_id}`: users and API credentials of the order's company; drivers only when $narrow agrees. +$registry->registerModel('order', Order::class, fn (SocketPrincipal $p, Order $order) => $p->kind === 'driver' && $p->owns((string) $order->driver_assigned_uuid)); + +// Anything else: fn (SocketPrincipal $p, string $id, string $channel): bool +$registry->register('fleet', fn (SocketPrincipal $p, string $id, string $channel) => /* ... */ false); + +// Claim a Sanctum-authenticated user as a more specific principal on `POST v1/socket/token`. +$registry->registerPrincipalResolver(fn (Request $request, $user) => /* ?SocketPrincipal */ null); +``` + +`app(ChannelAuthorizer::class)->authorize($principal, $channel)` gives the same decision anywhere in PHP. From f2e142ff3d91bc39ca7977b06d932aaf957fa80e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:03:48 +0800 Subject: [PATCH 06/10] feat(socket-auth): system socket token route for platform API callers POST v1/socket/system-token in the fleetbase.platform-api group mints a system token. A separate path because the platform and public API groups share the v1 prefix, where v1/socket/token is the public API mint route. --- src/routes.php | 2 ++ tests/Unit/RoutesContractTest.php | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/routes.php b/src/routes.php index ca1b9638..4d51c2b5 100644 --- a/src/routes.php +++ b/src/routes.php @@ -35,6 +35,8 @@ function ($router) { ->middleware(['fleetbase.platform-api']) ->group(function ($router) { $router->get('organizations', 'OrganizationController@listOrganizations'); + // Realtime socket token for the platform itself. + $router->post('socket/system-token', [Fleetbase\Http\Controllers\SocketAuthController::class, 'systemToken']); }); $router->prefix('v1') diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index 569b6550..a130adec 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -371,6 +371,7 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa $consoleToken = routes_contract_find($routes, 'POST', 'int/v1/socket/token'); $apiToken = routes_contract_find($routes, 'POST', 'v1/socket/token'); + $systemToken = routes_contract_find($routes, 'POST', 'v1/socket/system-token'); $authorize = routes_contract_find($routes, 'POST', 'int/v1/socket/authorize'); expect($consoleToken['action'])->toBe($controller . '@token') @@ -378,6 +379,9 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($apiToken['action'])->toBe($controller . '@apiToken') ->and($apiToken['middleware'])->toContain('fleetbase.api') ->and($apiToken['middleware'])->not->toContain('fleetbase.platform-api') + ->and($systemToken['action'])->toBe($controller . '@systemToken') + ->and($systemToken['middleware'])->toContain('fleetbase.platform-api') + ->and($systemToken['middleware'])->not->toContain('fleetbase.api') // Only the socket server calls this; its signature is the whole of its authentication. ->and($authorize['action'])->toBe($controller . '@authorizeChannel') ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); From 4ff2c6f8255b2a131cce9e4fb58b11930a79ba85 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:11:44 +0800 Subject: [PATCH 07/10] test(socket-auth): fix the missing-schema fixture and report every channel decision The install-channel test skipped the users table but still seeded it. The cross-company and driver tests now compare every channel's decision reason (and any resolver error logged) in one assertion. --- .../SocketChannelAuthorizationTest.php | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/tests/Unit/Support/SocketChannelAuthorizationTest.php b/tests/Unit/Support/SocketChannelAuthorizationTest.php index b539ddc8..83d796f0 100644 --- a/tests/Unit/Support/SocketChannelAuthorizationTest.php +++ b/tests/Unit/Support/SocketChannelAuthorizationTest.php @@ -60,6 +60,20 @@ function socket_channel_core_authorizer(): ChannelAuthorizer return new ChannelAuthorizer($registry); } +/** + * The decision reason for each channel, keyed by channel. + */ +function socket_channel_reasons(ChannelAuthorizer $authorizer, SocketPrincipal $principal, array $channels): array +{ + $reasons = []; + + foreach ($channels as $channel) { + $reasons[$channel] = $authorizer->authorize($principal, $channel)->reason; + } + + return $reasons; +} + afterEach(function () { SocketAuthFixtures::reset(); }); @@ -77,7 +91,7 @@ function socket_channel_core_authorizer(): ChannelAuthorizer ]); test('anonymous connections may follow the install channel only until setup creates a user', function () { - SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users']); + SocketAuthFixtures::database(SocketAuthFixtures::KEY, ['users'], false); $missingSchema = (new ChannelAuthorizer(new SocketChannelRegistry()))->authorize(null, 'fleetbase.install'); SocketAuthFixtures::database(SocketAuthFixtures::KEY, [], false); @@ -341,15 +355,19 @@ function socket_channel_core_authorizer(): ChannelAuthorizer $authorizer = socket_channel_core_authorizer(); $user = socket_channel_principal(); $api = socket_channel_principal(['kind' => 'api', 'sub' => 'cred-a', 'ids' => ['cred-a']]); - - foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1'] as $channel) { - expect($authorizer->authorize($user, $channel)->allow)->toBeTrue(); - } - - foreach (['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops'] as $channel) { - expect($authorizer->authorize($user, $channel)->toArray())->toBe(['allow' => false, 'ttl' => 30, 'reason' => 'forbidden']) - ->and($authorizer->authorize($api, $channel)->allow)->toBeFalse(); - } + $allowed = ['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.participant-a1', 'chat_message.chat_message_a', 'file.file_aaa', 'user.user-a2', 'api.cred-a', 'test.user-a1']; + $denied = ['chat.chat_bbb', 'chat_channel.chat-b', 'chat_participant.participant-b1', 'chat_message.chat_message_b', 'file.file_bbb', 'user.user_b1', 'company.company_bbb', 'api.cred-b', 'api.2', 'test.user-b1', 'install.company-b.fleetops']; + + // Collected rather than asserted one by one so a failure shows every reason and any resolver error. + expect([ + 'user' => socket_channel_reasons($authorizer, $user, array_merge($allowed, $denied)), + 'api' => socket_channel_reasons($authorizer, $api, $denied), + 'log' => app('log')->entries, + ])->toBe([ + 'user' => array_merge(array_fill_keys($allowed, 'resolver'), array_fill_keys($denied, 'forbidden')), + 'api' => array_fill_keys($denied, 'forbidden'), + 'log' => [], + ]); }); test('drivers reach only the chats they take part in', function () { @@ -357,14 +375,16 @@ function socket_channel_core_authorizer(): ChannelAuthorizer $authorizer = socket_channel_core_authorizer(); $driver = socket_channel_driver(); - - foreach (['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a', 'user.user-a1', 'driver.driver-1'] as $channel) { - expect($authorizer->authorize($driver, $channel)->allow)->toBeTrue(); - } - - foreach (['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a'] as $channel) { - expect($authorizer->authorize($driver, $channel)->allow)->toBeFalse(); - } + $allowed = ['chat.chat_aaa', 'chat_channel.chat-a', 'chat_participant.chat_participant_a1', 'chat_message.message-a']; + $denied = ['chat.chat_bbb', 'chat_participant.participant-b1', 'chat_message.message-b', 'user.user-a2', 'company.company-a', 'file.file_aaa', 'api.cred-a']; + + expect([ + 'driver' => socket_channel_reasons($authorizer, $driver, array_merge($allowed, ['user.user-a1', 'driver.driver-1'], $denied)), + 'log' => app('log')->entries, + ])->toBe([ + 'driver' => array_merge(array_fill_keys($allowed, 'resolver'), ['user.user-a1' => 'self', 'driver.driver-1' => 'self'], array_fill_keys($denied, 'forbidden')), + 'log' => [], + ]); expect($authorizer->authorize(socket_channel_driver(['ids' => ['driver-1']]), 'chat.chat_aaa')->allow)->toBeFalse() ->and($authorizer->authorize(socket_channel_driver(['ids' => []]), 'chat.chat_aaa')->allow)->toBeFalse() From 232704568e607862cebdaeb50fdfee47497185a4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:15:08 +0800 Subject: [PATCH 08/10] fix(socket-auth): skip default eager loads when resolving a channel's model ChatMessage always eager loads its attachments, so authorizing a chat_message channel queried chat_attachments for nothing (and failed where that table is absent). Channel lookups now load only the model's own row. --- src/Support/SocketCluster/ModelChannelResolver.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Support/SocketCluster/ModelChannelResolver.php b/src/Support/SocketCluster/ModelChannelResolver.php index 583df828..66279ce5 100644 --- a/src/Support/SocketCluster/ModelChannelResolver.php +++ b/src/Support/SocketCluster/ModelChannelResolver.php @@ -44,10 +44,13 @@ public function authorize(SocketPrincipal $principal, string $id, string $channe /** * Find a model by uuid or public_id in the principal's environment (sandbox for test). + * + * Relations a model always eager loads are skipped: authorization only reads its own columns. */ public static function find(string $modelClass, string $id, SocketPrincipal $principal): ?object { return $modelClass::on(static::connection($principal)) + ->setEagerLoads([]) ->where(function ($query) use ($id) { $query->where('uuid', $id)->orWhere('public_id', $id); }) From 11114518b39d2b4ac8a4f26eb127b505a7e70350 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 14:16:52 +0800 Subject: [PATCH 09/10] feat(socket-auth): SOCKETCLUSTER_AUTH_ENABLED switch, off by default Socket authentication was on as soon as SOCKETCLUSTER_AUTH_KEY was set. That also moved every broadcast to the socket server's HTTP publish endpoint, so provisioning the key before every client fetched tokens (or before the new socket server was deployed) would break existing socket clients. - New `broadcasting.connections.socketcluster.auth_enabled` (SOCKETCLUSTER_AUTH_ENABLED, default false). SocketToken::enabled() now needs the switch and a valid key. Every gated path follows: token routes, the authorize endpoint and its signature check, and HTTP publishing. - With the switch off, the console's socket test publishes to the requested channel (default `test`) as before. With it on, only to `test.{user}`. - README: the switch and the rollout order (ship clients that fall back on 404, switch on with the socket server in log mode, then enforce). --- README.md | 8 ++- config/broadcasting.connections.php | 7 +- .../Internal/v1/SettingController.php | 11 ++-- src/Support/SocketCluster/SocketToken.php | 18 ++++- tests/Fixtures/Support/SocketAuthFixtures.php | 9 +-- .../SettingControllerExternalProbesTest.php | 65 +++++++++++++++---- tests/Unit/Support/SocketTokenTest.php | 33 +++++++++- 7 files changed, 122 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index de43866d..bc1496b2 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,16 @@ Notes: ## Realtime channel authentication -Setting `SOCKETCLUSTER_AUTH_KEY` (a shared secret of at least 32 characters, also given to the socket server) turns on authenticated realtime channels. Without it nothing changes: no socket tokens are minted, the token routes answer 404, and broadcasts use the websocket publisher as before. +Authenticated realtime channels are on only when `SOCKETCLUSTER_AUTH_ENABLED=true` **and** `SOCKETCLUSTER_AUTH_KEY` is set (a shared secret of at least 32 characters, also given to the socket server). Until then nothing changes: no socket tokens are minted, the token routes answer 404, broadcasts use the websocket publisher as before, and the console's socket test publishes to the channel it asks for. + +The switch is separate from the key so a deployment can provision the key ahead of time and keep every existing socket client working (mobile apps, the console, integrations) until they all fetch socket tokens. Roll out in this order: +1. Ship clients that request a socket token and fall back to connecting without one when the token route answers 404. +2. Set `SOCKETCLUSTER_AUTH_ENABLED=true` on the API, queue and scheduler, and run the socket server with `SOCKETCLUSTER_AUTH_MODE=log`. +3. Check the socket server's deny log, then switch it to `enforce`. | Variable | Default | Meaning | |---|---|---| +| `SOCKETCLUSTER_AUTH_ENABLED` | `false` | Turns authenticated realtime channels on. Has no effect without `SOCKETCLUSTER_AUTH_KEY`. | | `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | | `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | | `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index 214514e3..65f0a568 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -24,8 +24,11 @@ 'query' => [], ], - // Realtime channel authentication. Leaving SOCKETCLUSTER_AUTH_KEY unset keeps the - // feature off: no socket tokens are minted and broadcasts use the websocket publisher. + // Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true + // and SOCKETCLUSTER_AUTH_KEY is set: until then no socket tokens are minted and + // broadcasts use the websocket publisher, so existing socket clients keep working. + // Turn it on once every client fetches socket tokens. + 'auth_enabled' => Utils::castBoolean(env('SOCKETCLUSTER_AUTH_ENABLED', false)), 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index df5a00e4..be04ac17 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1040,8 +1040,10 @@ public function testSentryConfig(AdminRequest $request) /** * Test SocketCluster Configuration. * - * Publishes only to the signed-in user's own `test.{user uuid}` channel; any channel in the - * request is ignored. The channel used is returned so the console can subscribe to it. + * With socket authentication on, publishes only to the signed-in user's own + * `test.{user uuid}` channel and ignores any channel in the request. With it off, publishes + * to the requested channel (default `test`) as before, so existing consoles keep working. + * The channel used is returned so the console can subscribe to it. * * @param Request $request the incoming HTTP request containing the authenticated user * @@ -1050,8 +1052,9 @@ public function testSentryConfig(AdminRequest $request) public function testSocketcluster(AdminRequest $request) { $userUuid = session('user'); + $scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled(); - if (!is_string($userUuid) || $userUuid === '') { + if ($scoped && (!is_string($userUuid) || $userUuid === '')) { return response()->json([ 'status' => 'error', 'message' => 'No signed-in user to publish the test message for.', @@ -1061,7 +1064,7 @@ public function testSocketcluster(AdminRequest $request) } // Get the channel to publish to - $channel = 'test.' . $userUuid; + $channel = $scoped ? 'test.' . $userUuid : (string) $request->input('channel', 'test'); $message = 'Socket broadcasted message successfully.'; $status = 'success'; $sent = false; diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php index 57ae85b6..c9115907 100644 --- a/src/Support/SocketCluster/SocketToken.php +++ b/src/Support/SocketCluster/SocketToken.php @@ -18,8 +18,9 @@ /** * Mints and verifies the HS256 tokens realtime clients present to the socket server. * - * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. Without it - * (or with one shorter than 32 bytes) the feature is off and nothing is minted. + * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. The feature is + * on only when SOCKETCLUSTER_AUTH_ENABLED is true and that key is at least 32 bytes long; + * otherwise nothing is minted and broadcasts use the websocket publisher. */ class SocketToken { @@ -54,9 +55,20 @@ public static function key(): ?string return is_string($key) && strlen($key) >= self::MIN_KEY_LENGTH ? $key : null; } + /** + * Whether realtime channel authentication is switched on. + * + * The switch is separate from the key so the key can be provisioned (for example on + * the socket server) before every socket client is ready for tokens. + */ + public static function switchedOn(): bool + { + return filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN); + } + public static function enabled(): bool { - return static::key() !== null; + return static::switchedOn() && static::key() !== null; } /** diff --git a/tests/Fixtures/Support/SocketAuthFixtures.php b/tests/Fixtures/Support/SocketAuthFixtures.php index fb09a309..22cf4f10 100644 --- a/tests/Fixtures/Support/SocketAuthFixtures.php +++ b/tests/Fixtures/Support/SocketAuthFixtures.php @@ -35,10 +35,11 @@ class SocketAuthFixtures public static function container(?string $key = self::KEY, array $config = []): Container { $container = bind_test_container(array_merge([ - 'broadcasting.connections.socketcluster.auth_key' => $key, - 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', - 'broadcasting.connections.socketcluster.token_ttl' => 900, - 'broadcasting.connections.socketcluster.options' => [ + 'broadcasting.connections.socketcluster.auth_enabled' => $key !== null, + 'broadcasting.connections.socketcluster.auth_key' => $key, + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + 'broadcasting.connections.socketcluster.token_ttl' => 900, + 'broadcasting.connections.socketcluster.options' => [ 'secure' => false, 'host' => 'socket.test', 'port' => 8000, diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index f3948b38..dd7538d3 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -169,41 +169,82 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ->and($twilio->messages)->toBe([]); }); -test('test socketcluster returns stable json when the configured socket cannot send', function () { +test('test socketcluster publishes to the requested channel while socket authentication is off', function () { setting_controller_external_probe_fixtures(); - session(['user' => 'user-probe']); + session()->flush(); - // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. + // Existing consoles pick their own test channel; that keeps working until auth is switched on. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ - 'channel' => 'company.someone-else', + 'channel' => 'settings-probe', ])); - session()->flush(); - expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', 'message' => 'Socket broadcasted message successfully.', - 'channel' => 'test.user-probe', + 'channel' => 'settings-probe', 'response' => null, ]); + + $default = (new SettingController())->testSocketcluster(setting_controller_external_probe_request()); + + expect($default->getData(true)['channel'])->toBe('test'); }); -test('test socketcluster refuses to publish without a signed-in user', function () { - setting_controller_external_probe_fixtures(); +test('test socketcluster ignores the key while the auth switch is off', function () { + setting_controller_external_probe_fixtures([ + 'broadcasting.connections.socketcluster.auth_enabled' => false, + 'broadcasting.connections.socketcluster.auth_key' => str_repeat('k', 40), + ]); session()->flush(); + $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'settings-probe', + ])); + + expect($response->getData(true)['channel'])->toBe('settings-probe'); +}); + +test('test socketcluster only publishes to the admin test channel while socket authentication is on', function () { + setting_controller_external_probe_fixtures([ + 'broadcasting.connections.socketcluster.auth_enabled' => true, + 'broadcasting.connections.socketcluster.auth_key' => str_repeat('k', 40), + 'broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001', + ]); + app()->instance(Illuminate\Http\Client\Factory::class, new Illuminate\Http\Client\Factory()); + Facade::clearResolvedInstances(); + Illuminate\Support\Facades\Http::fake(['*' => Illuminate\Support\Facades\Http::response('unavailable', 503)]); + session(['user' => 'user-probe']); + + // Any requested channel is ignored: the probe only ever publishes to the admin's own test channel. $response = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ 'channel' => 'company.someone-else', ])); + session()->flush(); + expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', - 'message' => 'No signed-in user to publish the test message for.', - 'channel' => null, - 'response' => null, + 'message' => 'Socket broadcasted message successfully.', + 'channel' => 'test.user-probe', + 'response' => 'unavailable', ]); + + $refused = (new SettingController())->testSocketcluster(setting_controller_external_probe_request([ + 'channel' => 'company.someone-else', + ])); + + expect($refused->getData(true))->toBe([ + 'status' => 'error', + 'message' => 'No signed-in user to publish the test message for.', + 'channel' => null, + 'response' => null, + ]); + + // Http::fake() swaps the container's client; put a real one back for later files. + app()->instance(Illuminate\Http\Client\Factory::class, new Illuminate\Http\Client\Factory()); + Facade::clearResolvedInstances(); }); test('test sentry config rejects invalid dsns before sdk fallback handling', function () { diff --git a/tests/Unit/Support/SocketTokenTest.php b/tests/Unit/Support/SocketTokenTest.php index ceddd783..68b98032 100644 --- a/tests/Unit/Support/SocketTokenTest.php +++ b/tests/Unit/Support/SocketTokenTest.php @@ -2,6 +2,7 @@ use Fleetbase\Models\ApiCredential; use Fleetbase\Support\SocketCluster\ChannelDecision; +use Fleetbase\Support\SocketCluster\SocketClusterService; use Fleetbase\Support\SocketCluster\SocketPrincipal; use Fleetbase\Support\SocketCluster\SocketSignature; use Fleetbase\Support\SocketCluster\SocketToken; @@ -26,8 +27,8 @@ public function __construct( SocketAuthFixtures::reset(); }); -test('socket tokens are disabled without a configured key of at least 32 bytes', function () { - SocketAuthFixtures::container(null); +test('socket tokens are disabled without a configured key of at least 32 bytes, even when switched on', function () { + SocketAuthFixtures::container(null, ['broadcasting.connections.socketcluster.auth_enabled' => true]); expect(SocketToken::key())->toBeNull() ->and(SocketToken::enabled())->toBeFalse(); @@ -37,12 +38,38 @@ public function __construct( expect(SocketToken::key())->toBeNull() ->and(SocketToken::enabled())->toBeFalse(); - config(['broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); + config(['broadcasting.connections.socketcluster.auth_enabled' => true, 'broadcasting.connections.socketcluster.auth_key' => SocketAuthFixtures::KEY]); expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) ->and(SocketToken::enabled())->toBeTrue(); }); +test('socket tokens stay off until the auth switch is on, even with a valid key', function () { + SocketAuthFixtures::container(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + + expect(SocketToken::key())->toBe(SocketAuthFixtures::KEY) + ->and(SocketToken::switchedOn())->toBeFalse() + ->and(SocketToken::enabled())->toBeFalse() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse(); + + // Values read from the environment arrive as strings. + config(['broadcasting.connections.socketcluster.auth_enabled' => 'true']); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeTrue(); + + config(['broadcasting.connections.socketcluster.auth_enabled' => 'false']); + + expect(SocketToken::enabled())->toBeFalse(); + + // The switch alone is not enough without a key. + config(['broadcasting.connections.socketcluster.auth_enabled' => true, 'broadcasting.connections.socketcluster.auth_key' => null]); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeFalse(); +}); + test('issuing a socket token requires the feature to be configured', function () { SocketAuthFixtures::container(null); From f961a5d48b8aca0e05cd80836a0de12089183260 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 10:31:22 +0800 Subject: [PATCH 10/10] fix(socket-auth): send a configurable Origin on the websocket publisher handshake The PHP publisher connected without an Origin header, which socketcluster-server treats as '*'. With origins restricted (scripts/docker-install.sh restricts them to the console host), every server broadcast was refused with 'Invalid origin: *'. SOCKETCLUSTER_ORIGIN now sets the header through phrity/websocket's headers option. Reported in fleetbase/core-api#290. --- README.md | 1 + config/broadcasting.connections.php | 5 +++++ tests/Unit/Support/SocketClusterTest.php | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/README.md b/README.md index bc1496b2..dbbb70c2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ The switch is separate from the key so a deployment can provision the key ahead | `SOCKETCLUSTER_AUTH_KEY` | unset | Signs socket tokens (HS256) and, through derived keys, the API to socket server requests. | | `SOCKETCLUSTER_PUBLISH_URL` | `http://{SOCKETCLUSTER_HOST}:8001` | The socket server's internal listener; broadcasts are sent as one signed `POST {url}/publish`. | | `SOCKETCLUSTER_TOKEN_TTL` | `900` | Lifetime in seconds of user, API, driver, customer and checkout tokens. | +| `SOCKETCLUSTER_ORIGIN` | unset | `Origin` header the websocket publisher sends on its handshake. Set it to an origin the socket server allows (e.g. the console URL) when `SOCKETCLUSTER_OPTIONS` restricts `origins`; without it the handshake is refused as `Invalid origin: *`. Not used by the signed HTTP publish. | Clients fetch a token before connecting: `POST int/v1/socket/token` (console session), `POST v1/socket/token` (API credential or Sanctum user token). The socket server asks `POST int/v1/socket/authorize`, signed with its own derived key, whether a token may subscribe to a channel. diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index 65f0a568..f124df25 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -22,6 +22,11 @@ 'port' => env('SOCKETCLUSTER_PORT', 8000), 'path' => env('SOCKETCLUSTER_PATH', '/socketcluster/'), 'query' => [], + // The websocket publisher sends no Origin of its own, and a socket server whose + // `origins` are restricted (as scripts/docker-install.sh sets them) rejects a + // handshake without one. Set SOCKETCLUSTER_ORIGIN to an allowed origin, e.g. the + // console URL. + 'headers' => array_filter(['Origin' => env('SOCKETCLUSTER_ORIGIN')]), ], // Realtime channel authentication. It is off unless SOCKETCLUSTER_AUTH_ENABLED is true diff --git a/tests/Unit/Support/SocketClusterTest.php b/tests/Unit/Support/SocketClusterTest.php index fdfccf63..5a17abce 100644 --- a/tests/Unit/Support/SocketClusterTest.php +++ b/tests/Unit/Support/SocketClusterTest.php @@ -265,6 +265,18 @@ function decode_socket_cluster_payload(string $payload): array ->and($service->getClient())->toBeInstanceOf(Client::class); }); +it('sends configured handshake headers such as Origin to the websocket client', function () { + $service = new SocketClusterService([ + 'secure' => false, + 'host' => 'socket.test', + 'headers' => ['Origin' => 'https://console.example.test'], + ]); + + $clientOptions = (fn () => $this->options)->call($service->getClient()); + + expect($clientOptions['headers'])->toBe(['Origin' => 'https://console.example.test']); +}); + it('broadcasts payloads to every channel through the socket cluster service', function () { $service = new RecordingSocketClusterService(); $broadcaster = new SocketClusterBroadcaster($service);