feat: additional roles per app
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Security\AccessTokenRoles;
|
||||
use App\Security\AppRegistry;
|
||||
use App\Security\IdTokenDecoder;
|
||||
use App\Session\BffSessionStore;
|
||||
@@ -20,6 +21,7 @@ class AuthController extends AbstractController
|
||||
private readonly BffSessionStore $store,
|
||||
private readonly IdTokenDecoder $idTokenDecoder,
|
||||
private readonly AppRegistry $apps,
|
||||
private readonly AccessTokenRoles $tokenRoles,
|
||||
private readonly string $kcBaseUrl,
|
||||
private readonly string $kcRealm,
|
||||
private readonly string $kcClientId,
|
||||
@@ -81,7 +83,6 @@ class AuthController extends AbstractController
|
||||
|
||||
$appKey = (string) $session->get('oauth_app');
|
||||
$returnUrl = $this->apps->resolveReturnUrl($appKey);
|
||||
$requiredRole = $this->apps->requiredRole($appKey);
|
||||
|
||||
$response = $this->client->request(
|
||||
'POST',
|
||||
@@ -111,8 +112,14 @@ class AuthController extends AbstractController
|
||||
// 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.
|
||||
//
|
||||
// The full role list is kept on the session so /auth/me can report
|
||||
// the app's finer-grained permissions. Those are display data for
|
||||
// the app's UI only; they are never re-checked per request here —
|
||||
// the backend authorizes off the forwarded access token.
|
||||
$accessClaims = $this->idTokenDecoder->decode($tokens['access_token']);
|
||||
if (!$this->hasRole($accessClaims, $requiredRole)) {
|
||||
$roles = $this->tokenRoles->extract($accessClaims);
|
||||
if (!in_array($this->apps->accessRole($appKey), $roles, true)) {
|
||||
$session->remove('pkce_verifier');
|
||||
$session->remove('oauth_state');
|
||||
$session->remove('oauth_app');
|
||||
@@ -134,6 +141,7 @@ class AuthController extends AbstractController
|
||||
'refresh_token' => $tokens['refresh_token'],
|
||||
'id_token' => $tokens['id_token'],
|
||||
'expires_at' => time() + (int) $tokens['expires_in'],
|
||||
'roles' => $roles,
|
||||
'profile' => [
|
||||
'email' => $idClaims['email'] ?? null,
|
||||
'email_verified' => $idClaims['email_verified'] ?? null,
|
||||
@@ -149,19 +157,20 @@ class AuthController extends AbstractController
|
||||
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.
|
||||
* 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
|
||||
* normal API query with `Authorization: Bearer <sid>` — same pattern as
|
||||
* /api/*, but served directly by the BFF since this data comes from the
|
||||
* tokens, not the backend.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Permissions here are for the app's own UI gating. They are not an
|
||||
* authorization boundary — anything that matters must be enforced by
|
||||
* the backend off the access token the proxy injects.
|
||||
*/
|
||||
#[Route('/auth/me', methods: ['GET'])]
|
||||
public function me(Request $request): JsonResponse
|
||||
@@ -171,12 +180,20 @@ class AuthController extends AbstractController
|
||||
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);
|
||||
}
|
||||
|
||||
return new JsonResponse($session['profile'] ?? []);
|
||||
return new JsonResponse(($session['profile'] ?? []) + [
|
||||
'app' => $appKey,
|
||||
'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* The one place that knows where an app's roles live in a Keycloak access
|
||||
* token: as client roles on the BFF's own confidential client. Both the
|
||||
* login gate and the token refresh read them through here.
|
||||
*/
|
||||
class AccessTokenRoles
|
||||
{
|
||||
public function __construct(private readonly string $kcClientId)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $accessTokenClaims decoded (and signature-verified) access token
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function extract(array $accessTokenClaims): array
|
||||
{
|
||||
return array_values($accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,18 @@ 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.
|
||||
* each paired with the Keycloak client role required to access it and the
|
||||
* prefix identifying that app's permission roles.
|
||||
*
|
||||
* 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' => ...] */
|
||||
/**
|
||||
* @param array<string,array{url:string,role_prefix:string,access_role:string}> $apps
|
||||
* app key => ['url' => ..., 'role_prefix' => ..., 'access_role' => ...]
|
||||
*/
|
||||
public function __construct(private readonly array $apps)
|
||||
{
|
||||
}
|
||||
@@ -26,12 +30,37 @@ class AppRegistry
|
||||
return $this->requireApp($appKey)['url'];
|
||||
}
|
||||
|
||||
public function requiredRole(string $appKey): string
|
||||
public function accessRole(string $appKey): string
|
||||
{
|
||||
return $this->requireApp($appKey)['role'];
|
||||
return $this->requireApp($appKey)['access_role'];
|
||||
}
|
||||
|
||||
/** @return array{url:string,role:string} */
|
||||
/**
|
||||
* Narrows every role the user holds on the bff client down to the ones
|
||||
* belonging to this app, reported without the app's prefix. The access
|
||||
* role shares the prefix, so it shows up here as "access" — intentional,
|
||||
* the prefix has no exceptions and apps are free to ignore it.
|
||||
*
|
||||
* @param list<string> $clientRoles every bff-client role the user holds
|
||||
*
|
||||
* @return list<string> this app's permissions, prefix stripped
|
||||
*/
|
||||
public function permissions(string $appKey, array $clientRoles): array
|
||||
{
|
||||
$prefix = $this->requireApp($appKey)['role_prefix'];
|
||||
|
||||
$permissions = [];
|
||||
foreach ($clientRoles as $role) {
|
||||
if (str_starts_with($role, $prefix)) {
|
||||
$permissions[] = substr($role, strlen($prefix));
|
||||
}
|
||||
}
|
||||
sort($permissions);
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/** @return array{url:string,role_prefix:string,access_role:string} */
|
||||
private function requireApp(string $appKey): array
|
||||
{
|
||||
if (!isset($this->apps[$appKey])) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Session;
|
||||
|
||||
use App\Security\AccessTokenRoles;
|
||||
use App\Security\IdTokenDecoder;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
@@ -12,6 +14,8 @@ class TokenRefresher
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $client,
|
||||
private readonly BffSessionStore $store,
|
||||
private readonly IdTokenDecoder $idTokenDecoder,
|
||||
private readonly AccessTokenRoles $tokenRoles,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $kcBaseUrl,
|
||||
private readonly string $kcRealm,
|
||||
@@ -64,6 +68,21 @@ class TokenRefresher
|
||||
$session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token'];
|
||||
$session['expires_at'] = time() + $tokens['expires_in'];
|
||||
|
||||
// Re-read the roles so /auth/me reflects permissions granted or
|
||||
// revoked in Keycloak mid-session. The access gate itself is not
|
||||
// re-evaluated here — "may this user use app X" stays a login-time
|
||||
// question, so losing the access role won't kill a live session.
|
||||
try {
|
||||
$session['roles'] = $this->tokenRoles->extract(
|
||||
$this->idTokenDecoder->decode($tokens['access_token'])
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Keep the previous list — a decode hiccup must not drop the session.
|
||||
$this->logger->warning('Could not re-read roles from refreshed access token', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->store->put($kcSid, $session);
|
||||
|
||||
return $session['access_token'];
|
||||
|
||||
Reference in New Issue
Block a user