feat: additional roles per app

This commit is contained in:
Björn Fromme
2026-08-12 08:57:11 +02:00
parent f51d699928
commit b62abb1801
6 changed files with 152 additions and 33 deletions
+32 -15
View File
@@ -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'] ?? []),
]);
}
/**