feat: implement full bff logic
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
<?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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
#[Route('/')]
|
||||
public function index(): Response
|
||||
{
|
||||
return new Response('¯\_(ツ)_/¯');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Security\BackendRegistry;
|
||||
use App\Session\TokenRefresher;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class ProxyController extends AbstractController
|
||||
{
|
||||
private const string DEFAULT_BACKEND_KEY = 'default';
|
||||
|
||||
private const array HOP_BY_HOP_HEADERS = [
|
||||
'connection', 'keep-alive', 'transfer-encoding', 'te',
|
||||
'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate',
|
||||
'host', 'content-length', 'x-backend',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly TokenRefresher $refresher,
|
||||
private readonly BackendRegistry $backends,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Appsmith API query in every app points here, sending
|
||||
* `Authorization: Bearer <sid>` (the value from appsmith.store.sid) —
|
||||
* never the real Keycloak token, which never leaves the BFF.
|
||||
*/
|
||||
#[Route('/api/{path}', requirements: ['path' => '.+'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])]
|
||||
public function proxy(Request $request, string $path): Response
|
||||
{
|
||||
$kcSid = $this->extractBearer($request);
|
||||
if (!$kcSid) {
|
||||
return $this->jsonError('unauthenticated', 401);
|
||||
}
|
||||
|
||||
$accessToken = $this->refresher->ensureFresh($kcSid);
|
||||
if (!$accessToken) {
|
||||
return $this->jsonError('session expired', 401);
|
||||
}
|
||||
|
||||
$backendKey = $request->headers->get('X-Backend', self::DEFAULT_BACKEND_KEY);
|
||||
if (!$this->backends->isValidBackend($backendKey)) {
|
||||
return $this->jsonError('unknown backend', 400);
|
||||
}
|
||||
$backendBaseUrl = $this->backends->resolveBaseUrl($backendKey);
|
||||
|
||||
$forwardHeaders = [];
|
||||
foreach ($request->headers->all() as $name => $values) {
|
||||
$lower = strtolower($name);
|
||||
if (!in_array($lower, self::HOP_BY_HOP_HEADERS, true) && $lower !== 'authorization') {
|
||||
$forwardHeaders[$name] = $values;
|
||||
}
|
||||
}
|
||||
$forwardHeaders['Authorization'] = "Bearer {$accessToken}";
|
||||
|
||||
try {
|
||||
$upstream = $this->client->request($request->getMethod(), "{$backendBaseUrl}/{$path}", [
|
||||
'query' => $request->query->all(),
|
||||
'body' => $request->getContent(),
|
||||
'headers' => $forwardHeaders,
|
||||
]);
|
||||
$status = $upstream->getStatusCode();
|
||||
$body = $upstream->getContent(false);
|
||||
$contentType = $upstream->getHeaders(false)['content-type'][0] ?? 'application/json';
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Proxy request to backend failed', [
|
||||
'backend' => $backendKey,
|
||||
'path' => $path,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return $this->jsonError('upstream unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response($body, $status, ['Content-Type' => $contentType]);
|
||||
}
|
||||
|
||||
private function extractBearer(Request $request): ?string
|
||||
{
|
||||
$header = $request->headers->get('Authorization', '');
|
||||
|
||||
return str_starts_with($header, 'Bearer ') ? substr($header, 7) : null;
|
||||
}
|
||||
|
||||
private function jsonError(string $message, int $status): Response
|
||||
{
|
||||
return $this->json(['error' => $message], $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* Allowlist of Appsmith apps this BFF will redirect back to after login,
|
||||
* each paired with the Keycloak client role required to access it.
|
||||
*
|
||||
* Never redirect to an arbitrary caller-supplied URL — always resolve
|
||||
* through this registry to prevent open-redirect abuse.
|
||||
*/
|
||||
class AppRegistry
|
||||
{
|
||||
/** @param array<string,array{url:string,role:string}> $apps app key => ['url' => ..., 'role' => ...] */
|
||||
public function __construct(private readonly array $apps)
|
||||
{
|
||||
}
|
||||
|
||||
public function isValidApp(string $appKey): bool
|
||||
{
|
||||
return isset($this->apps[$appKey]);
|
||||
}
|
||||
|
||||
public function resolveReturnUrl(string $appKey): string
|
||||
{
|
||||
return $this->requireApp($appKey)['url'];
|
||||
}
|
||||
|
||||
public function requiredRole(string $appKey): string
|
||||
{
|
||||
return $this->requireApp($appKey)['role'];
|
||||
}
|
||||
|
||||
/** @return array{url:string,role:string} */
|
||||
private function requireApp(string $appKey): array
|
||||
{
|
||||
if (!isset($this->apps[$appKey])) {
|
||||
throw new \InvalidArgumentException("Unknown app key: {$appKey}");
|
||||
}
|
||||
|
||||
return $this->apps[$appKey];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* Allowlist of backends the proxy may forward to, keyed by a name a
|
||||
* request selects via the X-Backend header (see ProxyController).
|
||||
*
|
||||
* Never forward to a caller-supplied URL — always resolve through this
|
||||
* registry, same principle as AppRegistry's redirect-target allowlist.
|
||||
*/
|
||||
class BackendRegistry
|
||||
{
|
||||
/** @param array<string,array{url:string}> $backends backend key => ['url' => ...] */
|
||||
public function __construct(private readonly array $backends)
|
||||
{
|
||||
}
|
||||
|
||||
public function isValidBackend(string $key): bool
|
||||
{
|
||||
return isset($this->backends[$key]);
|
||||
}
|
||||
|
||||
public function resolveBaseUrl(string $key): string
|
||||
{
|
||||
if (!isset($this->backends[$key])) {
|
||||
throw new \InvalidArgumentException("Unknown backend key: {$key}");
|
||||
}
|
||||
|
||||
return $this->backends[$key]['url'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Verifies the ID token signature against Keycloak's published JWKS before
|
||||
* trusting any claims from it (in particular the "sid" and "sub" claims we
|
||||
* key sessions on). Requires firebase/php-jwt.
|
||||
*/
|
||||
class IdTokenDecoder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly string $kcBaseUrl,
|
||||
private readonly string $kcRealm,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function decode(string $idToken): array
|
||||
{
|
||||
$jwks = $this->cache->get('keycloak_jwks_' . $this->kcRealm, function (ItemInterface $item) {
|
||||
$item->expiresAfter(3600);
|
||||
$response = $this->client->request(
|
||||
'GET',
|
||||
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/certs"
|
||||
);
|
||||
|
||||
return $response->toArray();
|
||||
});
|
||||
|
||||
$keys = JWK::parseKeySet($jwks);
|
||||
$claims = JWT::decode($idToken, $keys);
|
||||
|
||||
/** @var array<string,mixed> $decoded */
|
||||
$decoded = json_decode((string) json_encode($claims), true);
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Session;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Stores real Keycloak tokens server-side, keyed by Keycloak's own session
|
||||
* id (the "sid" ID token claim) rather than a per-login random id.
|
||||
*
|
||||
* Because Keycloak issues the same "sid" to every client (app) that
|
||||
* authenticates within one browser SSO session, every Appsmith app the
|
||||
* user logs into within that window resolves to this same entry. Global
|
||||
* logout is therefore a single delete, not a fan-out over many sessions.
|
||||
*
|
||||
* Backed by a PSR-6 cache pool (see config/packages/cache.yaml). Defaults
|
||||
* to the filesystem adapter, which is safe across concurrent PHP-FPM
|
||||
* workers on a single host and needs no extra service to run. If you ever
|
||||
* run more than one BFF instance, switch that pool's adapter to
|
||||
* cache.adapter.redis — nothing in this class needs to change.
|
||||
*/
|
||||
class BffSessionStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
private readonly int $ttlSeconds = 3600,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function put(string $kcSid, array $data): void
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($kcSid));
|
||||
$item->set($data);
|
||||
$item->expiresAfter($this->ttlSeconds);
|
||||
$this->cache->save($item);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function get(string $kcSid): ?array
|
||||
{
|
||||
$item = $this->cache->getItem($this->key($kcSid));
|
||||
|
||||
return $item->isHit() ? $item->get() : null;
|
||||
}
|
||||
|
||||
public function revoke(string $kcSid): void
|
||||
{
|
||||
$this->cache->deleteItem($this->key($kcSid));
|
||||
}
|
||||
|
||||
private function key(string $kcSid): string
|
||||
{
|
||||
// PSR-6 keys disallow {}()/\@: — hash to stay safe regardless of
|
||||
// what Keycloak's sid claim looks like.
|
||||
return 'bff_session_' . hash('sha256', $kcSid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Session;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class TokenRefresher
|
||||
{
|
||||
private const int EXPIRY_LEEWAY_SECONDS = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly BffSessionStore $store,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $kcBaseUrl,
|
||||
private readonly string $kcRealm,
|
||||
private readonly string $kcClientId,
|
||||
private readonly string $kcClientSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a currently-valid access token for this Keycloak session,
|
||||
* refreshing server-side if it's near expiry. Returns null if the
|
||||
* session doesn't exist or the refresh grant failed (caller should
|
||||
* respond 401 so the app bounces back to login).
|
||||
*/
|
||||
public function ensureFresh(string $kcSid): ?string
|
||||
{
|
||||
$session = $this->store->get($kcSid);
|
||||
if ($session === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (time() < ($session['expires_at'] - self::EXPIRY_LEEWAY_SECONDS)) {
|
||||
return $session['access_token'];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->request(
|
||||
'POST',
|
||||
"{$this->kcBaseUrl}/realms/{$this->kcRealm}/protocol/openid-connect/token",
|
||||
[
|
||||
'body' => [
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $this->kcClientId,
|
||||
'client_secret' => $this->kcClientSecret,
|
||||
'refresh_token' => $session['refresh_token'],
|
||||
],
|
||||
]
|
||||
);
|
||||
$tokens = $response->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
// Refresh token expired/revoked (e.g. Keycloak-side admin logout) — kill the session.
|
||||
$this->logger->warning('Keycloak token refresh failed, revoking session', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
$this->store->revoke($kcSid);
|
||||
return null;
|
||||
}
|
||||
|
||||
$session['access_token'] = $tokens['access_token'];
|
||||
$session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token'];
|
||||
$session['expires_at'] = time() + $tokens['expires_in'];
|
||||
|
||||
$this->store->put($kcSid, $session);
|
||||
|
||||
return $session['access_token'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user