|
- <?php
-
- declare(strict_types=1);
-
- namespace Core\Auth;
-
- use Core\Http\Session;
- use Stevenmaguire\OAuth2\Client\Provider\Keycloak;
-
- class KeycloakAuth
- {
- private Session $session;
- private PermissionService $permissions;
- private ?Keycloak $provider = null;
-
- /** Keycloak service-account roles to exclude from the app role list. */
- private const SYSTEM_ROLES = ['uma_authorization', 'offline_access', 'account', 'view-profile', 'manage-account', 'manage-account-links'];
-
- public function __construct(Session $session, PermissionService $permissions)
- {
- $this->session = $session;
- $this->permissions = $permissions;
- }
-
- // ── Auth state ────────────────────────────────────────────────────────────
-
- public function check(): bool
- {
- return ($this->session->get('auth', [])['is_authenticated'] ?? false) === true;
- }
-
- public function user(): ?AuthUser
- {
- return $this->check() ? AuthUser::fromSession($this->session->get('auth', [])) : null;
- }
-
- public function id(): ?string
- {
- return $this->user()?->keycloakId;
- }
-
- public function roles(): array
- {
- return $this->user()?->roles ?? [];
- }
-
- public function permissions(): array
- {
- return $this->user()?->permissions ?? [];
- }
-
- public function hasRole(string $role): bool
- {
- return in_array($role, $this->roles(), true);
- }
-
- public function can(string $permission): bool
- {
- return in_array($permission, $this->permissions(), true);
- }
-
- // ── Login flow ────────────────────────────────────────────────────────────
-
- /**
- * Build the Keycloak authorization URL and store the state for CSRF validation.
- */
- public function beginLogin(): string
- {
- $provider = $this->getProvider();
-
- $authUrl = $provider->getAuthorizationUrl([
- 'scope' => 'openid email profile',
- ]);
-
- $this->session->set('oauth_state', $provider->getState());
-
- return $authUrl;
- }
-
- /**
- * Exchange the authorization code for tokens, populate the session.
- *
- * @throws \RuntimeException on state mismatch or token exchange failure
- */
- public function handleCallback(string $code, string $returnedState): void
- {
- $storedState = $this->session->get('oauth_state');
- $this->session->forget('oauth_state');
-
- if ($storedState === null || !hash_equals((string) $storedState, $returnedState)) {
- throw new \RuntimeException('OAuth state mismatch — possible CSRF attempt.');
- }
-
- $provider = $this->getProvider();
- $token = $provider->getAccessToken('authorization_code', ['code' => $code]);
-
- // Decode the access-token JWT to extract realm/client role claims.
- // Signature verification is not needed here: the token arrived directly
- // from Keycloak over an authenticated server-to-server HTTPS exchange.
- $accessClaims = $this->decodeJwtPayload($token->getToken());
- $idTokenRaw = (string) ($token->getValues()['id_token'] ?? '');
-
- $realmRoles = (array) ($accessClaims['realm_access']['roles'] ?? []);
- $clientId = (string) env('KEYCLOAK_CLIENT_ID', '');
- $clientRoles = (array) ($accessClaims['resource_access'][$clientId]['roles'] ?? []);
-
- $appRoles = array_values(array_filter(
- array_unique(array_merge($realmRoles, $clientRoles)),
- static fn(string $r): bool => !in_array($r, self::SYSTEM_ROLES, true)
- ));
-
- // Fetch the user-profile claims from the userinfo endpoint.
- $ownerData = $provider->getResourceOwner($token)->toArray();
- $displayName = trim(($ownerData['given_name'] ?? '') . ' ' . ($ownerData['family_name'] ?? ''));
-
- if ($displayName === '') {
- $displayName = (string) ($ownerData['preferred_username'] ?? $ownerData['sub'] ?? '');
- }
-
- // Prevent session fixation: regenerate ID before writing auth data.
- $this->session->regenerate();
-
- $this->session->set('auth', [
- 'is_authenticated' => true,
- 'user' => [
- 'keycloak_id' => (string) ($ownerData['sub'] ?? $accessClaims['sub'] ?? ''),
- 'username' => (string) ($ownerData['preferred_username'] ?? ''),
- 'email' => (string) ($ownerData['email'] ?? ''),
- 'display_name' => $displayName,
- 'roles' => $appRoles,
- 'permissions' => $this->permissions->permissionsForRoles($appRoles),
- ],
- // id_token is stored solely to support Keycloak RP-initiated logout
- // (id_token_hint parameter). It is never used to derive identity or
- // resolve permissions. Do not pass it to the browser or log it.
- 'id_token' => $idTokenRaw !== '' ? $idTokenRaw : null,
- 'login_time' => time(),
- 'last_permission_refresh' => time(),
- ]);
- }
-
- // ── Logout ────────────────────────────────────────────────────────────────
-
- /**
- * Destroy the local session and return the Keycloak RP-initiated logout URL.
- */
- public function logout(): string
- {
- $idToken = $this->session->get('auth')['id_token'] ?? null;
- $redirectUri = (string) env('KEYCLOAK_LOGOUT_REDIRECT_URI', '/');
- $base = rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/');
- $realm = (string) env('KEYCLOAK_REALM', '');
-
- $this->session->destroy();
-
- $params = ['post_logout_redirect_uri' => $redirectUri];
-
- if ($idToken !== null) {
- $params['id_token_hint'] = $idToken;
- }
-
- return "{$base}/realms/{$realm}/protocol/openid-connect/logout?" . http_build_query($params);
- }
-
- // ── Internal ──────────────────────────────────────────────────────────────
-
- private function getProvider(): Keycloak
- {
- if ($this->provider === null) {
- $this->provider = new Keycloak([
- 'authServerUrl' => rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/'),
- 'realm' => (string) env('KEYCLOAK_REALM', ''),
- 'clientId' => (string) env('KEYCLOAK_CLIENT_ID', ''),
- 'clientSecret' => (string) env('KEYCLOAK_CLIENT_SECRET', ''),
- 'redirectUri' => (string) env('KEYCLOAK_REDIRECT_URI', ''),
- ]);
- }
-
- return $this->provider;
- }
-
- private function decodeJwtPayload(string $jwt): array
- {
- $parts = explode('.', $jwt);
-
- if (count($parts) !== 3) {
- return [];
- }
-
- $padded = str_pad(strtr($parts[1], '-_', '+/'), (int) ceil(strlen($parts[1]) / 4) * 4, '=');
- $decoded = base64_decode($padded, true);
-
- return $decoded !== false ? (json_decode($decoded, true) ?? []) : [];
- }
- }
|