Files
appsmith-bff/src/Controller/AuthController.php
T

224 lines
8.6 KiB
PHP

<?php
namespace App\Controller;
use App\Security\AppRegistry;
use App\Security\IdTokenDecoder;
use App\Session\BffSessionStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class AuthController extends AbstractController
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly BffSessionStore $store,
private readonly IdTokenDecoder $idTokenDecoder,
private readonly AppRegistry $apps,
private readonly string $kcBaseUrl,
private readonly string $kcRealm,
private readonly string $kcClientId,
private readonly string $kcClientSecret,
private readonly string $kcRedirectUri,
private readonly ?string $postLogoutRedirect = null,
) {
}
/**
* Entry point every app's login page navigates to (full browser
* redirect, e.g. via Appsmith's navigateTo — never a fetch/query,
* since Appsmith queries run server-side and can't perform this leg).
*/
#[Route('/auth/login', methods: ['GET'])]
public function login(Request $request): RedirectResponse
{
$appKey = (string) $request->query->get('app', '');
if (!$this->apps->isValidApp($appKey)) {
throw $this->createNotFoundException('Unknown or missing app key');
}
$verifier = $this->base64UrlEncode(random_bytes(64));
$challenge = $this->base64UrlEncode(hash('sha256', $verifier, true));
$state = bin2hex(random_bytes(16));
// Plain Symfony session cookie, used only for this short
// browser <-> Keycloak <-> browser leg. Not the app's session.
$session = $request->getSession();
$session->set('pkce_verifier', $verifier);
$session->set('oauth_state', $state);
$session->set('oauth_app', $appKey);
$params = http_build_query([
'client_id' => $this->kcClientId,
'response_type' => 'code',
'scope' => 'openid profile email',
'redirect_uri' => $this->kcRedirectUri,
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
return new RedirectResponse(
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/auth?{$params}"
);
}
#[Route('/auth/callback', methods: ['GET'])]
public function callback(Request $request): Response
{
$session = $request->getSession();
$expectedState = (string) $session->get('oauth_state', '');
$givenState = (string) $request->query->get('state', '');
if ($expectedState === '' || !hash_equals($expectedState, $givenState)) {
return new Response('Invalid or missing state', 401);
}
$appKey = (string) $session->get('oauth_app');
$returnUrl = $this->apps->resolveReturnUrl($appKey);
$requiredRole = $this->apps->requiredRole($appKey);
$response = $this->client->request(
'POST',
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token",
[
'body' => [
'grant_type' => 'authorization_code',
'client_id' => $this->kcClientId,
'client_secret' => $this->kcClientSecret,
'code' => $request->query->get('code'),
'redirect_uri' => $this->kcRedirectUri,
'code_verifier' => $session->get('pkce_verifier'),
],
]
);
$tokens = $response->toArray();
$idClaims = $this->idTokenDecoder->decode($tokens['id_token']);
$kcSid = $idClaims['sid'] ?? null;
if (!$kcSid) {
throw new \RuntimeException('Keycloak did not issue a "sid" claim on the ID token');
}
// Authorization gate: does this user hold the role required for
// the app they're trying to enter? Checked against the access
// token (not the ID token), since that's where client roles live.
// This is the only enforcement point — the proxy trusts any
// already-established session, by design, since "may this user
// use app X" is a login-time question, not a per-request one.
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
if (!$this->hasRole($accessClaims, $requiredRole)) {
$session->remove('pkce_verifier');
$session->remove('oauth_state');
$session->remove('oauth_app');
$separator = str_contains($returnUrl, '?') ? '&' : '?';
return new RedirectResponse($returnUrl . $separator . http_build_query(['error' => 'access_denied']));
}
$session->remove('pkce_verifier');
$session->remove('oauth_state');
$session->remove('oauth_app');
// Same kcSid -> same entry, whichever app is logging in. If another
// app already created this session, this simply refreshes it.
$this->store->put($kcSid, [
'user_id' => $idClaims['sub'],
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'id_token' => $tokens['id_token'],
'expires_at' => time() + (int) $tokens['expires_in'],
'profile' => [
'email' => $idClaims['email'] ?? null,
'email_verified' => $idClaims['email_verified'] ?? null,
'name' => $idClaims['name'] ?? null,
'given_name' => $idClaims['given_name'] ?? null,
'family_name' => $idClaims['family_name'] ?? null,
'preferred_username' => $idClaims['preferred_username'] ?? null,
],
]);
$separator = str_contains($returnUrl, '?') ? '&' : '?';
return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid]));
}
/** @param array<string,mixed> $accessTokenClaims */
private function hasRole(array $accessTokenClaims, string $role): bool
{
$roles = $accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? [];
return in_array($role, $roles, true);
}
/**
* Returns the logged-in user's profile (email, name, etc). Called by
* an app as a normal API query with `Authorization: Bearer <sid>` —
* same pattern as /api/*, but served directly by the BFF since this
* data comes from the ID token, not the backend.
*/
#[Route('/auth/me', methods: ['GET'])]
public function me(Request $request): JsonResponse
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
$session = $this->store->get($kcSid);
if ($session === null) {
return new JsonResponse(['error' => 'session expired'], 401);
}
return new JsonResponse($session['profile'] ?? []);
}
/**
* Global logout. Called by an app as a normal (server-side-executed)
* API query with `Authorization: Bearer <sid>`. Returns a URL rather
* than redirecting itself, since the app must perform the actual
* browser navigation to kill Keycloak's own SSO cookie.
*/
#[Route('/auth/logout', methods: ['POST'])]
public function logout(Request $request): JsonResponse
{
$kcSid = $this->extractBearer($request);
if (!$kcSid) {
return new JsonResponse(['error' => 'missing session'], 401);
}
$data = $this->store->get($kcSid);
$idToken = $data['id_token'] ?? null;
// One delete kills the session for every app that shared it.
$this->store->revoke($kcSid);
$params = http_build_query(array_filter([
'id_token_hint' => $idToken,
'post_logout_redirect_uri' => $this->postLogoutRedirect,
]));
return new JsonResponse([
'logout_url' => "{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/logout?{$params}",
]);
}
private function extractBearer(Request $request): ?string
{
$header = $request->headers->get('Authorization', '');
return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null;
}
private function base64UrlEncode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
}