headers->has('Authorization'); } public function authenticate(Request $request): Passport { $header = (string) $request->headers->get('Authorization', ''); if (!str_starts_with($header, 'Bearer ')) { throw new AuthenticationException('Bearer token missing.'); } $token = substr($header, 7); if ('' === $this->expectedToken || !hash_equals($this->expectedToken, $token)) { throw new AuthenticationException('Bearer token invalid.'); } // Single-session model โ€” there is one bridge "user", not per-end-user auth. return new SelfValidatingPassport(new UserBadge('bridge')); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { return null; } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response { return $this->problemJson($exception->getMessage()); } /** * Entry point invoked when access is denied without a triggered authenticator * (e.g. an anonymous request to a protected route). Without this, Symfony * returns its default `WWW-Authenticate: Form` 302/401, which clients * speaking JSON would never expect โ€” same shape as onAuthenticationFailure * keeps QML's RestClient error mapping consistent across both paths. */ public function start(Request $request, ?AuthenticationException $authException = null): Response { return $this->problemJson($authException?->getMessage() ?? 'Bearer token required.'); } private function problemJson(string $detail): JsonResponse { return new JsonResponse( [ 'type' => 'about:blank', 'title' => 'Unauthorized', 'status' => Response::HTTP_UNAUTHORIZED, 'detail' => $detail, ], Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'application/problem+json'], ); } }