Let’s play with the Symfony new security system: OpenId Connect with Keycloak

Laurent VOULLEMIER
The SensioLabs Tech Blog
6 min readFeb 10, 2022

(French version available here: Openid connect avec Keycloak)

Introduction

In this post, we will use Keycloak, an IAM implementing OpenId Connect as an SSO. The features and settings of Keycloak are many (other popular identity providers like Twitter, Facebook, and so on, 2FA, …). Here, we are going to use users directly registered in Keycloak. You can find the example presented in this post (which uses a Docker container for Keycloack) on my Github account. Please note that the code is intended for educational purposes. Some cases might not be designed for production usage.

The Authenticators

The Symfony new security system is based on Authenticators. The authenticator classes look like in their structure to the now deprecated security guard component. Although Guard was only one of the methods allowing to write an authentication system; some other systems were available (form-login, ldap…). The rework of the security component brings an extra consistency: All authentication methods now use an authenticator. This is the interface of authenticators:

supports method allows to activate the authenticator. In our case, what we want is Symfony to validate the authentication when the OpenId server redirects towards our website through the redirect-uri parameterized. So our code is:

onAuthenticationSuccess and onAuthenticationFailure methods are called in case of success or failure. Thanks to them, you can return a response. If null is returned instead, the action continues with the controller tied to the route. In our example, we didn’t create a controller for the openid_redirecturi route. Therefore we must return a response:

createAuthenticatedToken method creates the security token once the authentication is successful. This concept already existed with the previous security system. Still, tokens were created before the authentication validation. It’s not the case anymore. Here is the simplified code:

We notice the passport notion. Passports and badges are a new concept from this system (we will talk about them later). If the authenticator extends AbstractAuthenticator, the token is created for us. Here we decorate the AbstractAuthenticator method because we need to add an attribute to the token. The attribute value is a DTO compound by the JWT token, the refresh token, and the JWT expiration timestamp. The possibility to attach attributes also exists for the passports. It enables to pass extra data without creating a specific class for the passport and/or the security token.

Two extra interfaces are implemented; they are InteractiveAuthenticatorInterface and AuthenticationEntryPointInterface. The former adds an isInteractive method. When this method returns true, the user is authorized for the actions that need the attribute IS_AUTHENTICATED_FULLY. The latter, AuthenticationEntryPointInterface with its start method, specifies what action you need to perform when a non-authenticated user lands on a page that needs authentication. In our case, we want to redirect towards the authorization url of the Keycloak server:

The state put in session is a recommended way with Oauth or OpenId to protect against CSRF attacks.

Passport and badges

The passport is used to validate the authentication. Please note we do not keep it after the authentication process. Its main purpose is to be a container for the badges. The badges allow several things:
- Bear an information (ex: UserBadge)
- Make a validation (ex: PasswordCredentials)
- Trigger a feature (ex: RememberMeBadge, PasswordUpgradeBadge)

In its internals, Symfony dispatches a CheckPassportEvent event. Some listeners watch this event. They do — or don’t do — their actions depending on the badges in the passport. Badges can be resolved. Some of them are already resolved at creation time. And some others are only resolved if their associated listener validates its action (for instance, PasswordCredentials is a badge that needs to be resolved). After the checkPassportEvent event dispatching, the security component only confirms the authentication if all passport badges are resolved.
The authenticator authenticate method goal is to create a passport with needed badges from the request information. The implementation in our example:

For better readability, the code is simplified compared to the repository one (mainly about error management). In this method:
- We check the state previously set in the session to protect from CSRF attacks
- We retrieve the JWT and the refresh token from the Keycloak server thanks to the authorization code present in the request
- We create a passport of type SelfValidatingPassport. This passport should be used when our application doesn’t validate the credentials themselves. Thus the particularity of this passport is that it does not need a credential badge. For the same reason, we add a PreAuthenticatedUserBadge that enables to bypass the checkPreAuth of user checkers.
- We transfer data to passport through the attributes to allow to pass it to the security token created in the createAuthenticatedToken previously seen.

User providers

In Symfony, the goals of user providers are:
- To return an object implementing UserInterface, from an unique identifier (this one contained in UserBadge)
- To refresh this object on every request (generally to keep synchronized with the database data)

A Symfony application must have at least one user provider. Our case slightly different from the classic user providers where we retrieve user information from an external source (database, API…). User information is already present in the JWT; so the loading of the user from its unique identifier only consists in decoding the JWT:

There is no need to refresh the user too. But it will be the case if data from our database is attached to the user. supportClass method is directly tied to refreshUser. It allows determining which user provider must be used to refresh our user object in the session. We notice the statement $decoded->realm_access->roles to get roles from the JWT. We can define our roles in Keycloak to use them in our Symfony application. The only requirement is to respect the ROLE_* pattern. We can also see that we use a request attribute to shift the jwt expiration timestamp outside the user provider. This choice may be discussed but it seems to me that it is the least bad solution (against, for instance, having a property for JWT expiration in the object implementing UserInterface).

The biggest part of the authentication is now behind us. Still, to have a thorough authentication system, two things need to be addressed: the disconnection and the JWT renewal.

Disconnection

Symfony takes care of a big part of the disconnection process when a route or a path is defined under the logout key of the firewall. Below is the configuration used in our example:

The logout route is defined in the routing (by default, we redirect to the “/” path). The base behavior provided by Symfony may be enough in many situations. In our context of SSO, we want to go a bit further and disconnect the user from the Keycloak server. From version 5.1, Symfony dispatches a specific event during disconnection (LogoutEvent), we are simply going to plug into it to disconnect the user from the SSO:

JWT renewal

A JWT is intended to have a short lifetime. Actually, as long as it is valid, you don’t need to ask the server to update. However, when its expiration time is near, it must be renewed thanks to the refresh token. At this moment, the identity provider can send a new JWT with new data (roles…) if some modification happens or even an error if the user is not authorized anymore to access the SSO.

We leveraged a listener on kernel.request for this requirement. It may seem logical to do this in the refreshUser method of the user provider. It’s possible indeed, but the redirect (if needed, to the login page) is more difficult in some situations, for instance, when refreshUser is called during a twig template rendering (through a call to is_granted). It is our event listener (still in a simplified version compared to the Github repository version, for a sake of readability).

When the JWT is expired, we call the Keycloak server to have a new JWT and a new refresh token. Therefore, through the JWT, we can reload an up-to-date user version. If invalid_grant is returned by the SSO, the user is disconnected from the application.

Final thoughts

So ends this example of authentication with the Authenticator system. We notice that, even in a more complex authentication flow than a login/password check against a database, the Symfony components are, as often, flexible and fit almost all use cases.

--

--