steverhoades / oauth2-openid-connect-server

An OpenID Connect Server plugin for The PHP League's OAuth2 Server
MIT License
198 stars 41 forks source link

Support for `nonce` parameter #47

Open ThaDaVos opened 2 years ago

ThaDaVos commented 2 years ago

I am looking into a way of passing the nonce parameter through as discussed here: https://github.com/thephpleague/oauth2-server/issues/962

Any idea on how we can implement this, as it's required by the spec here: https://openid.net/specs/openid-connect-core-1_0.html

ThaDaVos commented 2 years ago

I've found a way and it seems to work correctly also:

First off, one has to override the AuthCodeGrant to include the nonce in it's payload - mine is inside an custom extension package I made for Laravel/Passport, but hope it helps:

<?php

namespace {SOME_NAMESPACE{;

use Illuminate\Support\Arr;
use League\OAuth2\Server\Grant\AuthCodeGrant;
use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
use League\OAuth2\Server\ResponseTypes\RedirectResponse;
use Nyholm\Psr7\Response as Psr7Response;

class OpenIdAuthCodeGrant extends AuthCodeGrant
{
    /**
     * {@inheritdoc}
     */
    public function completeAuthorizationRequest(AuthorizationRequest $authorizationRequest)
    {
        /** @var RedirectResponse $response */
        $response = parent::completeAuthorizationRequest($authorizationRequest);

        if (request()->query->has('nonce')) {
            $httpResponse = $response->generateHttpResponse(new Psr7Response());

            $redirectUri = Arr::first($httpResponse->getHeader('Location'));

            $parsed = parse_url($redirectUri);

            parse_str($parsed['query'], $query);

            $authCodePayload = json_decode($this->decrypt($query['code']), true);

            $authCodePayload['nonce'] = request()->query('nonce');

            $query['code'] = $this->encrypt(json_encode($authCodePayload));

            $parsed['query'] = http_build_query($query);

            $response->setRedirectUri($this->unparse_url($parsed));
        }

        return $response;
    }

    /**
     * Inverse of parse_url
     *
     * @param mixed $parsed_url
     * @return string
     */
    private function unparse_url($parsed_url)
    {
        $scheme   = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : '';
        $host     = isset($parsed_url['host']) ? $parsed_url['host'] : '';
        $port     = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';
        $user     = isset($parsed_url['user']) ? $parsed_url['user'] : '';
        $pass     = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : '';
        $pass     = ($user || $pass) ? "$pass@" : '';
        $path     = isset($parsed_url['path']) ? $parsed_url['path'] : '';
        $query    = isset($parsed_url['query']) ? '?' . $parsed_url['query'] : '';
        $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';
        return "$scheme$user$pass$host$port$path$query$fragment";
    }
}

After this is done, and it's registered on the AuthorizationServer, you can use it inside the IdTokenResponse as follows:

<?php

namespace {SOME_NAMESPACE{;

use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\UserEntityInterface;
use OpenIDConnectServer\IdTokenResponse as OpenIDConnectServerIdTokenResponse;

class IdTokenResponse extends OpenIDConnectServerIdTokenResponse
{
    protected function getBuilder(AccessTokenEntityInterface $accessToken, UserEntityInterface $userEntity)
    {
        $request = request();

        $builder = parent::getBuilder($accessToken, $userEntity)
            ->issuedBy($request->getScheme() . '://' . $request->getHost());

        $authCodePayload = json_decode($this->decrypt($request->input('code')), true);

        if (isset($authCodePayload['nonce'])) {
            $builder->withClaim('nonce', $authCodePayload['nonce']);
        }

        return $builder;
    }
}

And it will be available inside the id_token as it should

thejoelinux commented 2 years ago

hi @ThaDaVos,

I just finished to integrate oauth2-server and openid-connect to a Symfony application, and the next step was : supporting the nonce parameter ! You code will help me a lot !

I got to implement the /ket/set GET call to validate the id_token response. Not sure this is mandatory tho.

But for sure i will try to update the doc here, as the constructor for AuthorizationServer in https://github.com/thephpleague/oauth2-server has changed...

ThaDaVos commented 2 years ago

In what way did it change? Cause I still use the same as in the docs here - are you sure you're not accidentally looking at a different version of the AuthorizationServer? Maybe in V9 it will change (which isn't out yet)

Edit: My implementation is in Laravel BTW - so hope a lot will be the same for you @thejoelinux

thejoelinux commented 2 years ago

Docs says:

// Setup the authorization server
$server = new \League\OAuth2\Server\AuthorizationServer(
    $clientRepository,
    $accessTokenRepository,
    $scopeRepository,
    $privateKey,
    $publicKey,
    $responseType
);

But it's not the $publicKey any more, it's the encryptionKey (I use a Defuse\Crypto\Key btw).

$encryptionKey = Key::loadFromAsciiSafeString(getenv('DEFUSE_KEY'));

$this->server = new \League\OAuth2\Server\AuthorizationServer(
    $clientRepository,
    $accessTokenRepository,
    $scopeRepository,
    $privateKey,
    $encryptionKey,
    $responseType
);
ThaDaVos commented 2 years ago

Oh yeah, that's correct 😂

thejoelinux commented 2 years ago

To the Symfony users out there : you can use the code made by @ThaDaVos with some minor adaptations;

First, you probably don't have (or want) the Illuminate\Support\Arr, the request is not available the same way, so it's something like:

public function completeAuthorizationRequest(
        AuthorizationRequest $authorizationRequest
    ) {
        $request = Request::createFromGlobals();

        /** @var RedirectResponse $response */
        $response = parent::completeAuthorizationRequest($authorizationRequest);
        if ($request->get('nonce') != null) {

            $httpResponse = $response->generateHttpResponse(new Psr7Response());

            $redirectUri = $httpResponse->getHeader('Location');
            $parsed = parse_url($redirectUri[0]);
            parse_str($parsed['query'], $query);
            $authCodePayload = json_decode($this->decrypt($query['code']), true);
            $authCodePayload['nonce'] = $request->get('nonce');
            $query['code'] = $this->encrypt(json_encode($authCodePayload));
            $parsed['query'] = http_build_query($query);
            $response->setRedirectUri($this->unparse_url($parsed));
        }
        return $response;
    }

Then, don't forget to register it on the AuthorizationServer on the /authorize end-point. (I almost lose my mind trying to register the thing on the /token end-point).

The same same thing goes for the IdTokenResponse : replace the $request = request(); by the $request = Request::createFromGlobals(); and give it to the AuthorizationServer on the /token/ end-point.

May the force be with you, because that was not an easy one.

ThaDaVos commented 2 years ago

Yesterday I took a deep dive into and re-did it all - it's now spec compliant (as far as I know) as I now have support for hybrid flow too - including c_hash - took some time to implement though