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:
Serreau Jovann
2026-03-23 19:51:22 +01:00
parent 287e8c7292
commit 8b66cbd334
2 changed files with 96 additions and 0 deletions

View File

@@ -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']));

View File

@@ -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',