Add SSO login for API via Keycloak (GET /api/auth/login/sso)
Flow: 1. GET /api/auth/login/sso → redirect to Keycloak login page 2. User authenticates on Keycloak 3. Keycloak redirects to GET /api/auth/login/sso/validate?code=xxx&state=xxx 4. Validate exchanges OAuth code for Keycloak token, finds user, returns JWT - Finds user by keycloakId first, then by email fallback - Only ROLE_ORGANIZER can get a JWT - Response includes token + expiresAt + email - API doc updated with both SSO routes - SSO validate marked @codeCoverageIgnore (requires live Keycloak) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,15 @@ namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
#[Route('/api/auth')]
|
||||
class ApiAuthController extends AbstractController
|
||||
@@ -131,6 +134,63 @@ class ApiAuthController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/login/sso', name: 'app_api_auth_sso', methods: ['GET'])]
|
||||
public function sso(ClientRegistry $clientRegistry): RedirectResponse
|
||||
{
|
||||
return $clientRegistry->getClient('keycloak')->redirect(
|
||||
['openid', 'email', 'profile'],
|
||||
['redirect_uri' => $this->generateUrl('app_api_auth_sso_validate', [], UrlGeneratorInterface::ABSOLUTE_URL)],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore Requires live Keycloak
|
||||
*/
|
||||
#[Route('/login/sso/validate', name: 'app_api_auth_sso_validate', methods: ['GET'])]
|
||||
public function ssoValidate(
|
||||
ClientRegistry $clientRegistry,
|
||||
EntityManagerInterface $em,
|
||||
#[Autowire('%kernel.secret%')] string $appSecret,
|
||||
): JsonResponse {
|
||||
try {
|
||||
$client = $clientRegistry->getClient('keycloak');
|
||||
$accessToken = $client->getAccessToken();
|
||||
$keycloakUser = $client->fetchUserFromToken($accessToken);
|
||||
} catch (\Throwable) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Authentification SSO echouee.'], 401);
|
||||
}
|
||||
|
||||
$data = $keycloakUser->toArray();
|
||||
$keycloakId = $keycloakUser->getId();
|
||||
$email = $data['email'] ?? '';
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['keycloakId' => $keycloakId]);
|
||||
if (!$user) {
|
||||
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
}
|
||||
|
||||
if (!$user) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Aucun compte organisateur associe a ce SSO.'], 403);
|
||||
}
|
||||
|
||||
if (!\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Acces reserve aux organisateurs.'], 403);
|
||||
}
|
||||
|
||||
$token = $this->generateJwt($user, $appSecret);
|
||||
$expiresAt = (new \DateTimeImmutable())->modify('+'.self::JWT_TTL.' seconds');
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'expiresAt' => $expiresAt->format(\DateTimeInterface::ATOM),
|
||||
'email' => $user->getEmail(),
|
||||
],
|
||||
'error' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateJwt(User $user, string $appSecret): string
|
||||
{
|
||||
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
|
||||
@@ -194,6 +194,42 @@ class ApiDocController extends AbstractController
|
||||
429 => 'Trop de tentatives',
|
||||
],
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/api/auth/login/sso',
|
||||
'summary' => 'Connexion via SSO (Keycloak)',
|
||||
'description' => 'Redirige vers la page de connexion Keycloak. Apres authentification, l\'utilisateur est redirige vers /api/auth/login/sso/validate qui retourne le JWT.',
|
||||
'headers' => [],
|
||||
'params' => [],
|
||||
'request' => null,
|
||||
'response' => [
|
||||
'redirect' => ['type' => 'string', 'example' => '"https://auth.esy-web.dev/realms/..."'],
|
||||
],
|
||||
'statuses' => [
|
||||
302 => 'Redirection vers Keycloak',
|
||||
],
|
||||
],
|
||||
[
|
||||
'method' => 'GET',
|
||||
'path' => '/api/auth/login/sso/validate',
|
||||
'summary' => 'Callback SSO → JWT',
|
||||
'description' => 'Appele automatiquement apres la connexion Keycloak. Echange le code OAuth contre un token JWT E-Ticket. Retourne le token + email.',
|
||||
'headers' => [],
|
||||
'params' => [
|
||||
'code' => ['type' => 'string', 'required' => true, 'description' => 'Code OAuth (ajoute par Keycloak)'],
|
||||
'state' => ['type' => 'string', 'required' => true, 'description' => 'State CSRF (ajoute par Keycloak)'],
|
||||
],
|
||||
'request' => null,
|
||||
'response' => [
|
||||
'success' => ['type' => 'bool', 'example' => true],
|
||||
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00", "email": "orga@example.com"}'],
|
||||
],
|
||||
'statuses' => [
|
||||
200 => 'JWT genere via SSO',
|
||||
401 => 'Authentification SSO echouee',
|
||||
403 => 'Pas de compte organisateur associe',
|
||||
],
|
||||
],
|
||||
[
|
||||
'method' => 'POST',
|
||||
'path' => '/api/auth/refresh',
|
||||
|
||||
Reference in New Issue
Block a user