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) ?? []) : []; } }