Drenso / symfony-oidc

This project contains the Symfony OIDC bundle, which is directly based on https://github.com/jumbojett/OpenID-Connect-PHP
Apache License 2.0
59 stars 32 forks source link

[Question] Is Back-Channel Logout supported #40

Closed viu-x closed 1 year ago

viu-x commented 1 year ago

Hi,

I didn't found information for this, so my question is if OpenID Back-Channel Logout is supported out-of-the-box.

Thx Tobi

bobvandevijver commented 1 year ago

Yes, it is, but not recommended. See the readme: https://github.com/Drenso/symfony-oidc#logout.

Edit: Wait, back channel. No, we only support front channel, but either are not recommended.

viu-x commented 10 months ago

Hi @bobvandevijver,

thx for your answer and sorry that I have to come back again so late. I totally missed your edit and now realize that it's not possible. It might be not recommend but my customer uses this with all their custom tools/apps with their own OpenID implementation/server.

Any hint though how to tackle this?

bobvandevijver commented 10 months ago

You can hook into the LogoutEvent (which this bundle does as well, see https://github.com/Drenso/symfony-oidc/blob/master/src/EventListener/OidcEndSessionSubscriber.php) and execute your own back channel logout logic from there. If you need something from the OidcClient, you will need to decorate the service with your own.

lukasz-zaroda commented 3 months ago

Just so anyone else will stumble on this issue and will attempt to implement the back-channel logout themselves: The main problem here seems to be that in Symfony there is no easy way to invalidate all sessions of the user. To be able to find out which sessions are associated with the given user, your only sane option is to keep track of sessions in the database. When I figured this out, I understood why this is out of the scope of this module.

    /**
     * This is a special path for the back-channel logout invocation coming from the oidc provider.
     */
    #[Route('/logout_oidc', name: 'app_logout_oidc', methods: ['POST'])]
    public function logoutOidc(Request $request): Response
    {
        $logoutToken = $request->get('logout_token');
        if (!$logoutToken) {
            throw new NotFoundHttpException();
        }

        $unencryptedToken = OidcJwtHelper::parseToken($logoutToken);
        $sub = $unencryptedToken->claims()->get('sub');

        // We have the "sub" but now what?

        return new Response();
    }
lukasz-zaroda commented 3 months ago

There is one more way to do this, without changing the session storage, that came to my mind. It depends on the fact that the session object stores information about the last time the session was used. Knowing that, we can add a new field to our User entity, named e.g."lastGlobalLogout" storing a date of the last logout at the OIDC provider. The last logout date should be updated in the /logout_oidc controller. User can be found based on the "sub" value given by the OIDC provider in the logout token, assuming it's also stored in the entity's field.

Now we can make a subscriber:

class GlobalLogoutSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly TokenStorageInterface $tokenStorage,
    )
    {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            RequestEvent::class => 'onKernelRequest',
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $session = $request->getSession();
        if (!$event->isMainRequest()) {
            return;
        }
        $token = $this->tokenStorage->getToken();
        if (!$token) {
            return;
        }
        $user = $token->getUser();
        $sessionLastUsedTimestamp = $session->getMetadataBag()->getLastUsed();
        $lastGlobalLogout = $user->getLastGlobalLogout();

        if (!$lastGlobalLogout) {
            return;
        }

        $lastGlobalLogoutTimestamp = $lastGlobalLogout->getTimestamp();
        if ($sessionLastUsedTimestamp <= $lastGlobalLogoutTimestamp) {
            $this->tokenStorage->setToken(null);
            $session->invalidate();
        }
    }
}

So, basically, what happens is that every session that was last used BEFORE the logout at the OIDC provider, will be unusable, which will force the user to log in again, to make use of a new session.

lukasz-zaroda commented 3 months ago

@bobvandevijver The second approach could actually be handled by this bundle. We could make an interface for a user entity with something like setLastGlobalLogout and getLastGlobalLogout, and make a subscriber that would be doing logging out. Then we could describe in documentation how to implement a custom subscriber updating the lastGlobalLogout value for the user. By giving this step for the developer to implement, we won't be introducing dependency on Doctrine etc. It's up to him, how he updates users. Would you welcome a PR?

bobvandevijver commented 3 months ago

@lukasz-zaroda I prefer not to, as logging out is fundamentally broken with single sign on implementations and supporting something like this would relay the wrong impression.

Also, I believe that all session invalidation on logout, which is what your solution is, should be something that Symfony could provide based on the LogoutEvent.

For your implementation I would also recommend switching to the LogoutEvent, as relying on whether the user is returned to your site by the IdP can be unreliable.

lukasz-zaroda commented 3 months ago

Back-channel logout doesn't rely on user returning to my website, so I'm not sure what do you mean :) I'm also not sure why do you consider it "broken". For many people, it can be useful, they just need to be aware of the caveats. And they won't be aware of them if the topic is simply ignored. But it's up to you, I just found the back-channel logout useful for my case, and I'm prepared for instances when it might not go through for whatever reason.

bobvandevijver commented 3 months ago

That's right, back-channel is initiated by the user at the IdP, where the IdP calls an endpoint on the Symfony project. I was mixing front channel and back channel (once again 😔).

Regarding the broken logout, you can read https://communities.surf.nl/trust-en-identity/artikel/damn-you-single-sign-on for some background. While back channel logout tries to solve this issue, it is not a 100% guarantee. And those are the caveats, and most users aren't aware of those, or even expect to be logged out on other sites when not initiating it from there. Which is why logout in my applications will always only be local, and show a warning that single sign on was used.