feat: auth verification endpoint
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Security\AccessTokenRoles;
|
||||
use App\Security\AppRegistry;
|
||||
use App\Security\IdTokenDecoder;
|
||||
use App\Session\BffSessionStore;
|
||||
use App\Session\TokenRefresher;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
@@ -19,6 +20,7 @@ class AuthController extends AbstractController
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly BffSessionStore $store,
|
||||
private readonly TokenRefresher $refresher,
|
||||
private readonly IdTokenDecoder $idTokenDecoder,
|
||||
private readonly AppRegistry $apps,
|
||||
private readonly AccessTokenRoles $tokenRoles,
|
||||
@@ -157,6 +159,33 @@ class AuthController extends AbstractController
|
||||
return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard endpoint apps call on page load: is this sid a live session, and
|
||||
* may it enter *this* app? Deliberately returns no profile data, so it
|
||||
* stays cheap enough to run on every page load.
|
||||
*
|
||||
* This exists because an app's own login state (a `sid` in client-side
|
||||
* storage) is trivially forgeable — checking it client-side proves
|
||||
* nothing. It is still UI gating, not an authorization boundary: the
|
||||
* backend authorizes off the access token the proxy injects.
|
||||
*/
|
||||
#[Route('/auth/verify', methods: ['GET'])]
|
||||
public function verify(Request $request): JsonResponse
|
||||
{
|
||||
$appKey = (string) $request->query->get('app', '');
|
||||
|
||||
$session = $this->resolveAppSession($request, $appKey);
|
||||
if ($session instanceof JsonResponse) {
|
||||
return $session;
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'valid' => true,
|
||||
'app' => $appKey,
|
||||
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logged-in user's profile (email, name, etc) plus the
|
||||
* permissions they hold for the calling app. Called by an app as a
|
||||
@@ -166,7 +195,8 @@ class AuthController extends AbstractController
|
||||
*
|
||||
* The `app` query parameter is required: one session is shared by every
|
||||
* app the user opens, so the BFF can only scope permissions if the
|
||||
* caller says which app is asking.
|
||||
* caller says which app is asking — and so it can check the caller may
|
||||
* enter that app at all (same gate as /auth/verify).
|
||||
*
|
||||
* Permissions here are for the app's own UI gating. They are not an
|
||||
* authorization boundary — anything that matters must be enforced by
|
||||
@@ -175,19 +205,11 @@ class AuthController extends AbstractController
|
||||
#[Route('/auth/me', methods: ['GET'])]
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
$kcSid = $this->extractBearer($request);
|
||||
if (!$kcSid) {
|
||||
return new JsonResponse(['error' => 'unauthenticated'], 401);
|
||||
}
|
||||
|
||||
$appKey = (string) $request->query->get('app', '');
|
||||
if (!$this->apps->isValidApp($appKey)) {
|
||||
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
|
||||
}
|
||||
|
||||
$session = $this->store->get($kcSid);
|
||||
if ($session === null) {
|
||||
return new JsonResponse(['error' => 'session expired'], 401);
|
||||
$session = $this->resolveAppSession($request, $appKey);
|
||||
if ($session instanceof JsonResponse) {
|
||||
return $session;
|
||||
}
|
||||
|
||||
return new JsonResponse(($session['profile'] ?? []) + [
|
||||
@@ -226,6 +248,48 @@ class AuthController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer sid -> a session that is live *and* authorized for $appKey.
|
||||
*
|
||||
* Returns the session array, or the JsonResponse the caller should
|
||||
* return instead of a payload. Liveness goes through TokenRefresher
|
||||
* rather than a bare store lookup, so a session killed on the Keycloak
|
||||
* side (admin logout, revoked refresh token) fails here immediately
|
||||
* instead of lingering until the cache TTL — and so the roles checked
|
||||
* below are the ones the refresh just re-read.
|
||||
*
|
||||
* @return array<string,mixed>|JsonResponse
|
||||
*/
|
||||
private function resolveAppSession(Request $request, string $appKey): array|JsonResponse
|
||||
{
|
||||
$kcSid = $this->extractBearer($request);
|
||||
if (!$kcSid) {
|
||||
return new JsonResponse(['error' => 'unauthenticated'], 401);
|
||||
}
|
||||
|
||||
if (!$this->apps->isValidApp($appKey)) {
|
||||
return new JsonResponse(['error' => 'unknown or missing app key'], 400);
|
||||
}
|
||||
|
||||
if ($this->refresher->ensureFresh($kcSid) === null) {
|
||||
return new JsonResponse(['error' => 'session expired'], 401);
|
||||
}
|
||||
|
||||
$session = $this->store->get($kcSid);
|
||||
if ($session === null) {
|
||||
return new JsonResponse(['error' => 'session expired'], 401);
|
||||
}
|
||||
|
||||
// Same gate as callback(), re-run per call: one session is shared by
|
||||
// every app the user opens, so a sid on its own says nothing about
|
||||
// which app its holder may enter.
|
||||
if (!in_array($this->apps->accessRole($appKey), $session['roles'] ?? [], true)) {
|
||||
return new JsonResponse(['error' => 'access_denied'], 403);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function extractBearer(Request $request): ?string
|
||||
{
|
||||
$header = $request->headers->get('Authorization', '');
|
||||
|
||||
Reference in New Issue
Block a user