Description
Description
Currently (Symfony 6.3) OpenId is supported via 2 access token providers. The oidc_user_info
and oidc
which is sure a nice addition to the Symfony framework, thx to @vincentchalamon.
https://symfony.com/blog/new-in-symfony-6-3-openid-connect-token-handler
If I understand correctly both requires that the client
mostly some kind of Single Page Application via React, NextJS and so on handles the OpenId login and then send the access_token or the token via fetch/ajax to a Symfony endpoint. Like in api-platform demo here https://github.com/api-platform/demo/pull/265/files#diff-5ab785e05cae9d615d004abfb686bd0409560dc13e748411bb19856773824fd9
What I suggest is to implement additional a OpenId implementation via Authorization via code
.
https://openid.net/specs/openid-connect-core-1_0.html#codeExample
Beside the other authorization the code
response_type
returns instead of #fragments
a ?code=<code>
. This allows us to authorization without the usage of JavaScript which I think is specially in case symfony/ux and twig based and php rendered application nice.
Example
I want to list step by step what I think would be good to add on top of the existing services of current OpenId featoure of Symfony 6.3.
This target is to implement a Single Sign On (SSO) login like this:
In this example I'm using keycloak via github (where I already login) and got sucessfully logged in and returns via the claim: email
the custom user entity via a custom user provider.
1. OpenIdRedirectAuthController
The first thing what I think is required how we can link to the OpenId
login page, create a /auth
link to open id server. The /auth
link need to implemented this way https://openid.net/specs/openid-connect-core-1_0.html#codeExample. It requires also to write something into the session
of the user so my suggestion to handle this is that there is something like a security.main.openid_redirect_auth
route:
<a href="/open-id-login"> {# {{ path('security.main.openid_redirect_auth') }} #}
Login with OpenId
</a>
The generation of the /auth
url requires some properties:
- baseUri: e.g.:
http://keycloak/realms/master/protocol/openid-connect/
- clientId: e.g.:
symfony-app
created open id client (need first be created) - redirectUri: e.g.:
http://127.0.0.1:8000
- responseType: e.g.:
code
- state: e.g.:
random
need to be saved in session and checked later in Code Extractor - nonce: e.g.:
nonce
not sure about it yet - scope: e.g.:
openid
- codeChallenge: e.g.:
true
orfalse
<?php
namespace Sulu\OidcCode\Security\AccessToken\Oidc;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Uid\Uuid;
class OidcAuthRedirectController
{
public function __construct(
private string $baseUri,
private string $clientId,
private string $redirectUri,
private string $responseType = 'code',
private string $scope = 'openid',
private ?string $codeChallenge = null,
) {
}
public function __invoke(Request $request): Response
{
$state = Uuid::v4()->__toString();
$request->getSession()->set('_oidc_state', $state);
$nonce = Uuid::v4()->__toString();
$query = [
'response_type' => $this->responseType,
'scope' => $this->scope,
'client_id' => $this->clientId,
'state' => $state,
'nonce' => $nonce,
'redirect_uri' => $this->redirectUri,
];
if (!$this->codeChallenge) {
$codeVerifier = base64_encode(random_bytes(32));
$codeChallenge = base64_encode(match(strtolower($this->codeChallenge)) {
'S256' => hash('sha256', $codeVerifier, true),
'plain' => $codeVerifier,
default => throw new \RuntimeException('Code challange algorithm not found: ' . $this->codeChallenge),
});
$codeChallenge = rtrim($codeChallenge, '=');
$codeChallenge = urlencode($codeChallenge);
$query['code_challenge'] = $codeChallenge;
$query['code_challenge_method'] = $this->codeChallenge;
$request->getSession()->set('_oidc_code_verifier', $codeVerifier);
}
return new RedirectResponse($this->baseUri . 'auth?' . http_build_query($query));
}
}
The OidcAuthRedirectController
is not specific for the code
based authorization it also could used in cases for id_token
and token
response type via #fragments
for oidc
and oidc_user_info
but in case when work with PHP / Twig such mechanism is required.
2. OidcCodeExtractor
After sucessfull login on the OpenId Server (keycloak in my case) it redirects back to the given redirect_uri
with a ?state=...
and a &code=...
.
The code
in this case is NOT the access_token
but the code which we require to get the access_token
. But first we need additional Code Extractor
the existing ones only can extract the query but we also need to validate for security reasons also the saved state
value so the Code Extractor
could look like this:
<?php
namespace Sulu\OidcCode\Security\AccessToken\Oidc;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
final class OidcCodeExtractor implements AccessTokenExtractorInterface
{
public function extractAccessToken(Request $request): ?string
{
// maybe add additional check so this check is only done on redirect_uri and not on other routes
$parameter = $request->query->get('code');
$code = \is_string($parameter) ? $parameter : null;
if ($code === null) {
return null;
}
$state = $request->query->get('state');
$sessionState = $request->query->get('sessionState');
$savedState = $request->getSession()->get('_oidc_state');
if (!$state || $state !== $savedState) {
return null; // exception?
}
return $code;
}
}
Now we have extracted the code
and can go into the next step.
3. The OidcCodeHandler
The OidcCodeHandler is very similar to the existing OidcUserInfoTokenHandler.
In case we can use the OidcUserInfoTokenHandler
in it but first we need to get the access_token
for it via the from the token
endpoint documented here:
https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
<?php
namespace Sulu\OidcCode\Security\AccessToken\Oidc;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\AccessToken\Oidc\Exception\MissingClaimException;
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class OidcCodeHandler implements AccessTokenHandlerInterface
{
private OidcUserInfoTokenHandler $oidcUserInfoTokenHandler;
public function __construct(
private HttpClientInterface $client, // client with base_uri
string $baseUri, `http://127.0.0.1:8080/realms/master/protocol/openid-connect/`
#[\SensitiveParameter] private string $clientId, // openid/keycloak clientId
#[\SensitiveParameter] private string $clientSecret, // openid/keycloak credentials client secret
private string $redirectUri, // need to be same as for OidcAuthRedirectController
private ?LoggerInterface $logger = null,
string $claim = 'sub',
) {
$this->oidcUserInfoTokenHandler = new OidcUserInfoTokenHandler(
$client->withOptions([
'base_uri' => $baseUri . 'userinfo', // see https://github.com/symfony/symfony/issues/50433 for better solution
]),
$logger,
$claim
);
}
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
{
try {
$data = $this->client->request(
'POST',
'token',
[
'auth_basic' => $this->clientId . ':' . $this->clientSecret,
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=authorization_code&code=' . $accessToken . '&redirect_uri=' . $this->redirectUri,
],
)->toArray();
$accessToken = $data['access_token'] ?? null;
if (!$accessToken) {
throw new MissingClaimException(sprintf('The "access_token" not found on OIDC server response.'));
}
return $this->oidcUserInfoTokenHandler->getUserBadgeFrom($accessToken);
} catch (BadCredentialsException $e) {
throw $e; // avoid double logging for BadCredentialsException
} catch (\Exception $e) {
$this->logger?->error('An error occurred on OIDC server.', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
throw new BadCredentialsException('Invalid code.', $e->getCode(), $e);
}
}
}
Via this 3 additional things it would also be possible to use OpenId inside Symfony without any JS implementation and handling. As I'm not an expert in this topic I hope somebody can correct me if I did interpret something in the existing implementation or in the suggestion wrong.
From configuration point of view little bit more as in the user info is required:
token_handler:
oidc_code:
claim: email
base_uri: 'http://keycloak.localhost:8080/realms/master/protocol/openid-connect/' # /auth, /token and /userinfo is used see https://github.com/symfony/symfony/issues/50433 for maybe better way to get this uris
client_id: 'symfony-app'
client_secret: 'secret...'
redirect_uri: 'https://127.0.0.1:8000' # https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
scope: openid # 'openid profile email phone'
code_challenge: ~ # can be 'S256', 'plain', null
# # client: oidc.client # custom http client
Here the steps which we are doing in this process:
Click "Login with Keycloak
-> Create Redirect link with required parameter for /auth
-> Login on Keycloak or via Identity Provider on Keycloak
-> Redirect back to Symfony
-> Extract Code and valid state
-> Get access_token via /token Api
-> Authenticate user via /userinfo Api
-> Login sucessfully
What is missing?
- Error handling is currently not implemented even keycloak returns errors they are not handled so not sure if that should be handled in
OidcCodeExtractor
or somewhere else. - Even the error is logged in the
OidcCodeExtractor
currently not possible to get it to twig or redirect to login page and show error viaAuthenticationUtils
- In case of successfull login via
redirect_uri?state=...&code=...
trough OidcCodeExtractor and OidcCodeHandler a redirect should happen this is maybe what make this oidc specially
Hope somebody can validate if I missed some steps and checks in case of security validation.
It is also worth mentioning @l-vo https://github.com/l-vo/sf_keycloak_example. Or mention by @bobvandevijver https://github.com/Drenso/symfony-oidc