Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security] OpenID Code Token Single Sign On implementation #50896

Open
alexander-schranz opened this issue Jul 5, 2023 · 3 comments
Open

[Security] OpenID Code Token Single Sign On implementation #50896

alexander-schranz opened this issue Jul 5, 2023 · 3 comments

Comments

@alexander-schranz
Copy link
Contributor

alexander-schranz commented Jul 5, 2023

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:

login-key-cloak

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 or false
<?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 via AuthenticationUtils
  • 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

@bobvandevijver
Copy link
Contributor

There is a bundle for that: https://github.com/Drenso/symfony-oidc! (which is shameless self promotion, sorry about that)

@alexander-schranz
Copy link
Contributor Author

alexander-schranz commented Jul 10, 2023

@bobvandevijver Thank you, I'm aware of the third party implementations did add it also above to the description, but as we already added openid access_token based authentication in Symfony Core, I think it make sense to also have openid code based authentication supported in the core.

@carsonbot
Copy link

Thank you for this issue.
There has not been a lot of activity here for a while. Has this been resolved?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants