<?php

declare(strict_types=1);

/*
 * Copyright (c) 2017-2023 François Kooman <fkooman@tuxed.net>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace fkooman\OAuth\Server\Tests;

use DateInterval;
use DateTimeImmutable;
use fkooman\OAuth\Server\Base64UrlSafe;
use fkooman\OAuth\Server\ClientInfo;
use fkooman\OAuth\Server\Exception\InvalidClientException;
use fkooman\OAuth\Server\Exception\InvalidGrantException;
use fkooman\OAuth\Server\Exception\InvalidRequestException;
use fkooman\OAuth\Server\Exception\InvalidScopeException;
use fkooman\OAuth\Server\Http\Request;
use fkooman\OAuth\Server\Http\Response;
use fkooman\OAuth\Server\Json;
use fkooman\OAuth\Server\PdoStorage;
use fkooman\OAuth\Server\Scope;
use fkooman\OAuth\Server\SimpleClientDb;
use fkooman\OAuth\Server\StorageInterface;
use PDO;
use PHPUnit\Framework\TestCase;

/**
 * @internal
 *
 * @coversNothing
 */
final class OAuthServerTest extends TestCase
{
    private StorageInterface $storage;
    private TestOAuthServer $server;
    private DateTimeImmutable $dateTime;

    protected function setUp(): void
    {
        $this->dateTime = new DateTimeImmutable('2016-01-01T00:00:00+00:00');
        $clientDb = new SimpleClientDb();
        $clientDb->add(new ClientInfo('code-client', ['http://example.org/code-cb'], null, null, false));
        $clientDb->add(new ClientInfo('code-client-foo-scope', ['http://example.org/code-cb'], null, null, false, new Scope('foo')));
        $clientDb->add(new ClientInfo('code-client-query-redirect', ['http://example.org/code-cb?keep=this'], null, 'Code Client', true));
        $clientDb->add(new ClientInfo('code-client-secret', ['http://example.org/code-cb'], '123456', 'Code Client', true));
        $clientDb->add(new ClientInfo('loopback', ['http://127.0.0.1:{PORT}/cb'], null, 'Loopback Client', true));

        $this->storage = new PdoStorage(new PDO('sqlite::memory:'));
        $this->storage->init();
        $this->server = new TestOAuthServer(
            $this->storage,
            $clientDb,
            $this->dateTime
        );
    }

    public function testAuthorizeCode(): void
    {
        static::assertSame(
            [
                'client_id' => 'code-client',
                'display_name' => 'code-client',
                'scope' => 'config',
                'redirect_uri' => 'http://example.org/code-cb',
            ],
            $this->server->getAuthorize(
                new Request(
                    [],
                    [
                        'client_id' => 'code-client',
                        'redirect_uri' => 'http://example.org/code-cb',
                        'response_type' => 'code',
                        'scope' => 'config',
                        'state' => '12345',
                        'code_challenge_method' => 'S256',
                        'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                    ],
                    []
                )
            )
        );
    }

    public function testAuthorizeCodeInvalidScope(): void
    {
        try {
            $this->server->getAuthorize(
                new Request(
                    [],
                    [
                        'client_id' => 'code-client-foo-scope',
                        'redirect_uri' => 'http://example.org/code-cb',
                        'response_type' => 'code',
                        'scope' => 'bar',
                        'state' => '12345',
                        'code_challenge_method' => 'S256',
                        'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                    ],
                    []
                )
            );
            static::fail();
        } catch (InvalidScopeException $e) {
            static::assertSame('invalid_scope', $e->getMessage());
            static::assertSame('"scope" [bar] not allowed for client "code-client-foo-scope"', $e->getDescription());
        }
    }

    public function testGetAuthorizeResponse(): void
    {
        $authorizeResponse = $this->server->getAuthorizeResponse(
            'foo',
            new Request(
                [],
                [
                    'client_id' => 'code-client',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'response_type' => 'code',
                    'scope' => 'config',
                    'state' => '12345',
                    'code_challenge_method' => 'S256',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ],
                []
            )
        );

        $expectedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        static::assertInstanceOf(Response::class, $authorizeResponse);
        static::assertSame(
            [
                'Location' => sprintf('http://example.org/code-cb?code=%s&state=12345&iss=%s', $expectedCode, urlencode('https://as.example.org')),
                'Content-Type' => 'text/html; charset=utf-8',
            ],
            $authorizeResponse->getHeaders()
        );
    }

    public function testGetAuthorizeResponseWithExpiresAt(): void
    {
        $authorizeResponse = $this->server->getAuthorizeResponse(
            'foo',
            new Request(
                [],
                [
                    'client_id' => 'code-client',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'response_type' => 'code',
                    'scope' => 'config',
                    'state' => '12345',
                    'code_challenge_method' => 'S256',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ],
                []
            ),
            new DateInterval('P7D'),
        );

        $expectedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-01-08T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        static::assertInstanceOf(Response::class, $authorizeResponse);
        static::assertSame(
            [
                'Location' => sprintf('http://example.org/code-cb?code=%s&state=12345&iss=%s', $expectedCode, urlencode('https://as.example.org')),
                'Content-Type' => 'text/html; charset=utf-8',
            ],
            $authorizeResponse->getHeaders()
        );
    }

    public function testAuthorizeCodePost(): void
    {
        $authorizeResponse = $this->server->postAuthorize(
            'foo',
            new Request(
                [],
                [
                    'client_id' => 'code-client',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'response_type' => 'code',
                    'scope' => 'foo',
                    'state' => '12345',
                    'code_challenge_method' => 'S256',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ],
                [
                    'approve' => 'yes',
                ],
            )
        );
        static::assertSame(302, $authorizeResponse->getStatusCode());

        $expectedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'foo',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        static::assertSame(
            [
                'Location' => sprintf('http://example.org/code-cb?code=%s&state=12345&iss=%s', $expectedCode, urlencode('https://as.example.org')),
                'Content-Type' => 'text/html; charset=utf-8',
            ],
            $authorizeResponse->getHeaders()
        );
    }

    public function testAuthorizeTokenPostRedirectUriWithQuery(): void
    {
        $authorizeResponse = $this->server->postAuthorize(
            'foo',
            new Request(
                [],
                [
                    'client_id' => 'code-client-query-redirect',
                    'redirect_uri' => 'http://example.org/code-cb?keep=this',
                    'response_type' => 'code',
                    'scope' => 'config',
                    'state' => '12345',
                    'code_challenge_method' => 'S256',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ],
                [
                    'approve' => 'yes',
                ]
            )
        );
        static::assertSame(302, $authorizeResponse->getStatusCode());

        $expectedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-query-redirect',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb?keep=this',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        static::assertSame(
            [
                'Location' => sprintf('http://example.org/code-cb?keep=this&code=%s&state=12345&iss=%s', $expectedCode, urlencode('https://as.example.org')),
                'Content-Type' => 'text/html; charset=utf-8',
            ],
            $authorizeResponse->getHeaders()
        );
    }

    public function testPostToken(): void
    {
        $providedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        $tokenResponse = $this->server->postToken(
            new Request(
                [],
                [],
                [
                    'grant_type' => 'authorization_code',
                    'code' => $providedCode,
                    'redirect_uri' => 'http://example.org/code-cb',
                    'client_id' => 'code-client',
                    'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                ]
            )
        );
        static::assertSame(200, $tokenResponse->getStatusCode());
        static::assertSame(
            [
                'Content-Type' => 'application/json',
                'Cache-Control' => 'no-store',
                'Pragma' => 'no-cache',
            ],
            $tokenResponse->getHeaders()
        );

        $expectedAccessToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'access_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T01:00:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );
        $expectedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        static::assertSame(
            [
                'access_token' => $expectedAccessToken,
                'refresh_token' => $expectedRefreshToken,
                'token_type' => 'bearer',
                'expires_in' => 3600,
            ],
            json_decode($tokenResponse->getBody(), true)
        );
    }

    public function testPostTokenSecret(): void
    {
        $providedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        $tokenResponse = $this->server->postToken(
            new Request(
                [
                    'PHP_AUTH_USER' => 'code-client-secret',
                    'PHP_AUTH_PW' => '123456',
                ],
                [],
                [
                    'grant_type' => 'authorization_code',
                    'code' => $providedCode,
                    'redirect_uri' => 'http://example.org/code-cb',
                    'client_id' => 'code-client-secret',
                    'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                ],
            )
        );

        $expectedAccessToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'access_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T01:00:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );
        $expectedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        static::assertSame(
            [
                'access_token' => $expectedAccessToken,
                'refresh_token' => $expectedRefreshToken,
                'token_type' => 'bearer',
                'expires_in' => 3600,
            ],
            json_decode($tokenResponse->getBody(), true)
        );
    }

    public function testPostTokenMissingCodeVerifier(): void
    {
        $providedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        try {
            $this->storage->storeAuthorization('foo', 'code-client', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));
            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => $providedCode,
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client',
                    ]
                )
            );
            static::fail();
        } catch (InvalidRequestException $e) {
            static::assertSame('missing "code_verifier" parameter', $e->getDescription());
        }
    }

    public function testPostTokenNotBoundToClient(): void
    {
        $providedCode = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'authorization_code',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'another-client-id',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:05:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                    'redirect_uri' => 'http://example.org/code-cb',
                    'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                ]
            )
        );

        try {
            $this->storage->storeAuthorization('foo', 'code-client', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));
            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => $providedCode,
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client',
                        'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                    ]
                )
            );
            static::fail();
        } catch (InvalidRequestException $e) {
            static::assertSame('unexpected "client_id"', $e->getDescription());
        }
    }

    public function testBrokenPostToken(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [],
                    [],
                    ['client_id' => 'code-client']
                )
            );
            static::fail();
        } catch (InvalidRequestException $e) {
            static::assertSame('missing "grant_type" parameter', $e->getDescription());
        }
    }

    public function testPostTokenSecretInvalid(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                        'PHP_AUTH_PW' => '654321',
                    ],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => 'DEF.abcdefgh',
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client-secret',
                        'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                    ],
                )
            );
            static::fail();
        } catch (InvalidClientException $e) {
            static::assertSame('invalid "password" provided', $e->getDescription());
        }
    }

    public function testPostReuseCode(): void
    {
        try {
            $this->storage->storeAuthorization('foo', 'code-client', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

            $providedCode = Base64UrlSafe::encodeUnpadded(
                Json::encode(
                    [
                        'type' => 'authorization_code',
                        'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                        'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                        'user_id' => 'foo',
                        'client_id' => 'code-client',
                        'scope' => 'config',
                        'expires_at' => '2016-01-01T00:05:00+00:00',
                        'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                        'redirect_uri' => 'http://example.org/code-cb',
                        'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                    ]
                )
            );

            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => $providedCode,
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client',
                        'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                    ]
                )
            );
            static::fail();
        } catch (InvalidGrantException $e) {
            static::assertSame('"authorization_code" was used before', $e->getDescription());
        }
    }

    public function testExpiredCode(): void
    {
        try {
            $providedCode = Base64UrlSafe::encodeUnpadded(
                Json::encode(
                    [
                        'type' => 'authorization_code',
                        'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                        'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                        'user_id' => 'foo',
                        'client_id' => 'code-client',
                        'scope' => 'config',
                        'expires_at' => '2015-01-01T00:05:00+00:00',
                        'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                        'redirect_uri' => 'http://example.org/code-cb',
                        'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                    ]
                )
            );

            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => $providedCode,
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client',
                        'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                    ]
                )
            );
            static::fail();
        } catch (InvalidGrantException $e) {
            static::assertSame('"authorization_code" expired', $e->getDescription());
        }
    }

    public function testAccessTokenAsCode(): void
    {
        try {
            $this->storage->storeAuthorization('foo', 'code-client', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

            $providedCode = Base64UrlSafe::encodeUnpadded(
                Json::encode(
                    [
                        'type' => 'access_token',
                        'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                        'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                        'user_id' => 'foo',
                        'client_id' => 'code-client',
                        'scope' => 'config',
                        'expires_at' => '2016-01-01T01:00:00+00:00',
                    ]
                )
            );

            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'grant_type' => 'authorization_code',
                        'code' => $providedCode,
                        'redirect_uri' => 'http://example.org/code-cb',
                        'client_id' => 'code-client',
                        'code_verifier' => 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
                    ]
                )
            );
            static::fail();
        } catch (InvalidGrantException $e) {
            static::assertSame('"type" MUST be "authorization_code"', $e->getDescription());
        }
    }

    public function testExpiredRefreshToken(): void
    {
        try {
            $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2015-12-31T00:00:00+00:00'));

            $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
                Json::encode(
                    [
                        'type' => 'refresh_token',
                        'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                        'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                        'user_id' => 'foo',
                        'client_id' => 'code-client-secret',
                        'scope' => 'config',
                        'expires_at' => '2015-12-31T00:00:00+00:00',
                    ]
                )
            );

            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'grant_type' => 'refresh_token',
                        'refresh_token' => $providedRefreshToken,
                    ]
                )
            );
            static::fail();
        } catch (InvalidGrantException $e) {
            static::assertSame('"refresh_token" expired', $e->getDescription());
        }
    }

    public function testRefreshToken(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

        $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        $tokenResponse = $this->server->postToken(
            new Request(
                [
                    'PHP_AUTH_USER' => 'code-client-secret',
                    'PHP_AUTH_PW' => '123456',
                ],
                [],
                [
                    'grant_type' => 'refresh_token',
                    'scope' => 'config',
                    'refresh_token' => $providedRefreshToken,
                ]
            )
        );

        $expectedAccessToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'access_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T01:00:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        $expectedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        static::assertSame(
            [
                'access_token' => $expectedAccessToken,
                'refresh_token' => $expectedRefreshToken,
                'token_type' => 'bearer',
                'expires_in' => 3600,
            ],
            json_decode($tokenResponse->getBody(), true)
        );
    }

    public function testRefreshTokenNotBoundToClient(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

        $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'a-different-client',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'grant_type' => 'refresh_token',
                        'scope' => 'config',
                        'refresh_token' => $providedRefreshToken,
                    ]
                )
            );
            static::fail();
        } catch (InvalidRequestException $e) {
            static::assertSame('unexpected "client_id"', $e->getDescription());
        }
    }

    public function testRefreshTokenAboutToExpire(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-01-01T00:30:00+00:00'));

        $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:30:00+00:00',
                ]
            )
        );

        $tokenResponse = $this->server->postToken(
            new Request(
                [
                    'PHP_AUTH_USER' => 'code-client-secret',
                    'PHP_AUTH_PW' => '123456',
                ],
                [],
                [
                    'grant_type' => 'refresh_token',
                    'scope' => 'config',
                    'refresh_token' => $providedRefreshToken,
                ]
            )
        );

        $expectedAccessToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'access_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:30:00+00:00',
                    'authorization_expires_at' => '2016-01-01T00:30:00+00:00',
                ]
            )
        );

        $expectedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T00:30:00+00:00',
                ]
            )
        );

        static::assertSame(
            [
                'access_token' => $expectedAccessToken,
                'refresh_token' => $expectedRefreshToken,
                'token_type' => 'bearer',
                'expires_in' => 1800,
            ],
            json_decode($tokenResponse->getBody(), true)
        );
    }

    public function testRefreshTokenWithoutExplicitScope(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

        $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        $tokenResponse = $this->server->postToken(
            new Request(
                [
                    'PHP_AUTH_USER' => 'code-client-secret',
                    'PHP_AUTH_PW' => '123456',
                ],
                [],
                [
                    'grant_type' => 'refresh_token',
                    'refresh_token' => $providedRefreshToken,
                ]
            )
        );

        $expectedAccessToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'access_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-01-01T01:00:00+00:00',
                    'authorization_expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        $expectedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('22222222222222222222222222222222'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        static::assertSame(
            [
                'access_token' => $expectedAccessToken,
                'refresh_token' => $expectedRefreshToken,
                'token_type' => 'bearer',
                'expires_in' => 3600,
            ],
            json_decode($tokenResponse->getBody(), true)
        );
    }

    public function testRefreshTokenReplay(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client-secret', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));

        $providedRefreshToken = Base64UrlSafe::encodeUnpadded(
            Json::encode(
                [
                    'type' => 'refresh_token',
                    'token_id' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'auth_key' => Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'),
                    'user_id' => 'foo',
                    'client_id' => 'code-client-secret',
                    'scope' => 'config',
                    'expires_at' => '2016-03-31T00:00:00+00:00',
                ]
            )
        );

        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'grant_type' => 'refresh_token',
                        'refresh_token' => $providedRefreshToken,
                    ]
                )
            );

            // not replayed yet, authorization should still be there
            static::assertNotNull($this->storage->getAuthorization(Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111')));

            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'grant_type' => 'refresh_token',
                        'refresh_token' => $providedRefreshToken,
                    ]
                )
            );
            static::fail();
        } catch (InvalidGrantException $e) {
            static::assertSame('"refresh_token" was used before', $e->getDescription());
        }

        // make sure the authorization is no longer there
        static::assertNull($this->storage->getAuthorization(Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111')));
    }

    public function testLoopbackClient(): void
    {
        static::assertSame(
            [
                'client_id' => 'loopback',
                'display_name' => 'Loopback Client',
                'scope' => 'config',
                'redirect_uri' => 'http://127.0.0.1:12345/cb',
            ],
            $this->server->getAuthorize(
                new Request(
                    [],
                    [
                        'client_id' => 'loopback',
                        'redirect_uri' => 'http://127.0.0.1:12345/cb',
                        'response_type' => 'code',
                        'scope' => 'config',
                        'state' => '12345',
                        'code_challenge_method' => 'S256',
                        'code_challenge' => 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
                    ],
                    []
                )
            )
        );
    }

    public function testDeleteAuthorization(): void
    {
        $this->storage->storeAuthorization('foo', 'code-client', new Scope('config'), Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $this->dateTime, new DateTimeImmutable('2016-03-31T00:00:00+00:00'));
        $authorizationList = $this->storage->getAuthorizations('foo');
        static::assertCount(1, $authorizationList);
        $authorization = array_pop($authorizationList);
        static::assertSame(Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'), $authorization->authKey());
        static::assertSame('code-client', $authorization->clientId());
        static::assertSame('config', (string) $authorization->scope());
        static::assertCount(1, $this->storage->getAuthorizations('foo'));
        $this->storage->deleteAuthorization(Base64UrlSafe::encodeUnpadded('11111111111111111111111111111111'));
        static::assertCount(0, $this->storage->getAuthorizations('foo'));
    }

    public function testMetadata(): void
    {
        $this->assertSame(
            [
                'issuer' => 'https://as.example.org',
                'authorization_endpoint' => 'https://as.example.org/authorize',
                'token_endpoint' => 'https://as.example.org/token',
                'response_types_supported' => ['code'],
                'grant_types_supported' => ['authorization_code', 'refresh_token'],
                'token_endpoint_auth_methods_supported' => ['client_secret_basic'],
                'code_challenge_methods_supported' => ['S256'],
            ],
            TestOAuthServer::metadata(
                'https://as.example.org',
                'https://as.example.org/authorize',
                'https://as.example.org/token',
            )
        );
    }

    public function testMismatchBasicUserAndClientId(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'client_id_one',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'client_id' => 'client_id_two',
                        'grant_type' => 'refresh_token',
                        'refresh_token' => 'xyz',
                    ]
                )
            );
            $this->fail();
        } catch (InvalidClientException $e) {
            $this->assertSame('invalid_client', $e->getMessage());
            $this->assertSame('HTTP Basic authentication "username" MUST match POST parameter "client_id" if the latter is specified', $e->getDescription());
        }
    }

    public function testBasicAuthForPublicClient(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client',
                        'PHP_AUTH_PW' => '123456',
                    ],
                    [],
                    [
                        'grant_type' => 'refresh_token',
                        'refresh_token' => 'xyz',
                    ]
                )
            );
            $this->fail();
        } catch (InvalidClientException $e) {
            $this->assertSame('invalid_client', $e->getMessage());
            $this->assertSame('for "public" clients HTTP Basic authentication MUST NOT be used', $e->getDescription());
        }
    }

    public function testConfidentialClientNoSecret(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [
                        'PHP_AUTH_USER' => 'code-client-secret',
                    ],
                    [],
                    [
                        'client_id' => 'code-client-secret',
                        'grant_type' => 'refresh_token',
                        'refresh_token' => 'xyz',
                    ]
                )
            );
            $this->fail();
        } catch (InvalidClientException $e) {
            $this->assertSame('invalid_client', $e->getMessage());
            $this->assertSame('no "password" provided', $e->getDescription());
        }
    }

    public function testConfidentialClientNoBasicAuth(): void
    {
        try {
            $this->server->postToken(
                new Request(
                    [],
                    [],
                    [
                        'client_id' => 'code-client-secret',
                        'grant_type' => 'refresh_token',
                        'refresh_token' => 'xyz',
                    ]
                )
            );
            $this->fail();
        } catch (InvalidClientException $e) {
            $this->assertSame('invalid_client', $e->getMessage());
            $this->assertSame('for "confidential" clients HTTP Basic authentication MUST be used', $e->getDescription());
        }
    }
}
