<?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;

use DateInterval;
use DateTimeImmutable;
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\Exception\SignerException;
use fkooman\OAuth\Server\Http\JsonResponse;
use fkooman\OAuth\Server\Http\RedirectResponse;
use fkooman\OAuth\Server\Http\Request;
use RuntimeException;

class OAuthServer
{
    protected const RANDOM_LENGTH = 32;

    protected DateTimeImmutable $dateTime;

    protected DateInterval $authorizationCodeExpiry;

    protected DateInterval $accessTokenExpiry;

    protected DateInterval $refreshTokenExpiry;

    private StorageInterface $storage;

    private ClientDbInterface $clientDb;

    private SignerInterface $signer;

    /**
     * RFC 9207 Issuer Identity.
     */
    private string $issuerIdentity;

    public function __construct(StorageInterface $storage, ClientDbInterface $clientDb, SignerInterface $signer, string $issuerIdentity)
    {
        // @see https://www.php.net/manual/en/mbstring.configuration.php#ini.mbstring.func-overload
        if (false !== (bool) ini_get('mbstring.func_overload')) {
            throw new RuntimeException('"mbstring.func_overload" MUST NOT be enabled');
        }
        $this->storage = $storage;
        $this->clientDb = $clientDb;
        $this->signer = $signer;
        $this->issuerIdentity = $issuerIdentity;
        $this->dateTime = Dt::get();
        $this->authorizationCodeExpiry = new DateInterval('PT5M'); // 5 minutes
        $this->accessTokenExpiry = new DateInterval('PT1H');       // 1 hour
        $this->refreshTokenExpiry = new DateInterval('P90D');      // 90 days
    }

    /**
     * Validates the authorization request from the client and returns verified
     * data to show an authorization dialog.
     *
     * @return array{client_id:string,display_name:string,scope:string,redirect_uri:string}
     */
    public function getAuthorize(?Request $request = null): array
    {
        $request ??= Request::fromServerVariables();
        $clientInfo = $this->validateAuthorizeRequest($request);

        return [
            'client_id' => $request->query()->clientId(),
            'display_name' => $clientInfo->displayName(),
            'scope' => (string) $request->query()->scope(),
            'redirect_uri' => $request->query()->redirectUri(),
        ];
    }

    /**
     * Validates the authorization request from the client and returns
     * authorize response in case the client does NOT require approval by the
     * user (resource owner).
     *
     * @return false|Http\Response
     */
    public function getAuthorizeResponse(string $userId, ?Request $request = null, ?DateInterval $authorizationExpiresAt = null)
    {
        $request ??= Request::fromServerVariables();
        $clientInfo = $this->validateAuthorizeRequest($request);
        if ($clientInfo->requiresApproval()) {
            return false;
        }

        return $this->postAuthorizeApproved($userId, $authorizationExpiresAt ?? $this->refreshTokenExpiry, $request);
    }

    public function postAuthorize(string $userId, ?Request $request = null, ?DateInterval $authorizationExpiresAt = null): RedirectResponse
    {
        $request ??= Request::fromServerVariables();
        $this->validateAuthorizeRequest($request);
        if ('yes' === $request->post()->approve()) {
            return $this->postAuthorizeApproved($userId, $authorizationExpiresAt ?? $this->refreshTokenExpiry, $request);
        }

        // user did not approve, tell OAuth client
        return new RedirectResponse(
            $this->prepareRedirectUri(
                $request->query()->redirectUri(),
                [
                    'error' => 'access_denied',
                    'state' => $request->query()->state(),
                    'iss' => $this->issuerIdentity,
                ]
            )
        );
    }

    /**
     * Handles POST request to the "/token" endpoint of the OAuth server.
     */
    public function postToken(?Request $request = null): JsonResponse
    {
        $request ??= Request::fromServerVariables();
        $clientInfo = $this->idAndAuthClient($request->post()->clientId(), $request->authUser(), $request->authPass());
        switch ($request->post()->grantType()) {
            case 'authorization_code':
                return $this->postTokenAuthorizationCode($clientInfo, $request);

            case 'refresh_token':
                return $this->postTokenRefreshToken($clientInfo, $request);

            default:
                throw new InvalidRequestException('invalid "grant_type"');
        }
    }

    public static function metadata(string $issuerIdentity, string $authorizationEndpoint, string $tokenEndpoint): array
    {
        return [
            'issuer' => $issuerIdentity,
            'authorization_endpoint' => $authorizationEndpoint,
            'token_endpoint' => $tokenEndpoint,
            'response_types_supported' => ['code'],
            'grant_types_supported' => ['authorization_code', 'refresh_token'],
            'token_endpoint_auth_methods_supported' => ['client_secret_basic'],
            'code_challenge_methods_supported' => ['S256'],
        ];
    }

    protected function randomBytes(): string
    {
        return random_bytes(self::RANDOM_LENGTH);
    }

    /**
     * Handles POST request to the "/authorize" endpoint of the OAuth server.
     *
     * This is typically the "form submit" on the "authorize dialog" shown in
     * the browser that the user then accepts or rejects.
     */
    private function postAuthorizeApproved(string $userId, DateInterval $authorizationExpiresAt, Request $request): RedirectResponse
    {
        // every "authorization" has a unique key that is bound to the
        // authorization code, access tokens(s) and refresh token
        $authKey = Base64UrlSafe::encodeUnpadded($this->randomBytes());

        $authorizationCode = new AuthorizationCode(
            Base64UrlSafe::encodeUnpadded($this->randomBytes()),
            $authKey,
            $userId,
            $request->query()->clientId(),
            $request->query()->scope(),
            $this->dateTime->add($this->authorizationCodeExpiry),
            $this->dateTime->add($authorizationExpiresAt),
            $request->query()->redirectUri(),
            $request->query()->codeChallenge()
        );

        return new RedirectResponse(
            $this->prepareRedirectUri(
                $request->query()->redirectUri(),
                [
                    'code' => $this->signer->sign($authorizationCode->json()),
                    'state' => $request->query()->state(),
                    'iss' => $this->issuerIdentity,
                ]
            )
        );
    }

    /**
     * Validate the request to the "/authorize" endpoint.
     */
    private function validateAuthorizeRequest(Request $request): ClientInfo
    {
        $clientInfo = $this->getClient($request->query()->clientId());

        // make sure the requested redirect URI is allowed for this client
        if (!$clientInfo->isValidRedirectUri($request->query()->redirectUri())) {
            throw new InvalidClientException(
                sprintf(
                    '"redirect_uri" [%s] not allowed for client "%s"',
                    $request->query()->redirectUri(),
                    $clientInfo->clientId()
                )
            );
        }

        // make sure the requested scope(s) are allowed for this client
        if (null !== $allowedScope = $clientInfo->allowedScope()) {
            if (false === $allowedScope->containsAll($request->query()->scope())) {
                throw new InvalidScopeException(
                    sprintf(
                        '"scope" [%s] not allowed for client "%s"',
                        (string) $request->query()->scope(),
                        $clientInfo->clientId()
                    )
                );
            }
        }

        return $clientInfo;
    }

    private function postTokenAuthorizationCode(ClientInfo $clientInfo, Request $request): JsonResponse
    {
        try {
            // verify the authorization code signature
            $authorizationCode = AuthorizationCode::fromJson($this->signer->verify($request->post()->code()));

            // check authorization_code expiry
            if ($this->dateTime >= $authorizationCode->expiresAt()) {
                throw new InvalidGrantException('"authorization_code" expired');
            }

            // make sure the code is bound to the client
            if ($clientInfo->clientId() !== $authorizationCode->clientId()) {
                throw new InvalidRequestException('unexpected "client_id"');
            }

            if ($request->post()->redirectUri() !== $authorizationCode->redirectUri()) {
                throw new InvalidRequestException('unexpected "redirect_uri"');
            }

            if (!self::verifyPkce($request->post()->codeVerifier(), $authorizationCode->codeChallenge())) {
                throw new InvalidGrantException('invalid "code_verifier"');
            }

            // check whether authorization_code was already used
            if (null !== $this->storage->getAuthorization($authorizationCode->authKey())) {
                $this->storage->deleteAuthorization($authorizationCode->authKey());

                throw new InvalidGrantException('"authorization_code" was used before');
            }

            // delete user's expired authorizations
            $this->storage->deleteExpiredAuthorizations($authorizationCode->userId(), $this->dateTime);

            // store the authorization
            $this->storage->storeAuthorization(
                $authorizationCode->userId(),
                $authorizationCode->clientId(),
                $authorizationCode->scope(),
                $authorizationCode->authKey(),
                $this->dateTime,
                $authorizationCode->authorizationExpiresAt()
            );

            // make sure an access_token never outlives its authorization, i.e.
            // the refresh_token expiry
            $accessTokenExpiresAt = $this->dateTime->add($this->accessTokenExpiry);
            if ($accessTokenExpiresAt > $authorizationCode->authorizationExpiresAt()) {
                $accessTokenExpiresAt = $authorizationCode->authorizationExpiresAt();
            }

            $accessToken = new AccessToken(
                Base64UrlSafe::encodeUnpadded($this->randomBytes()),
                $authorizationCode->authKey(),
                $authorizationCode->userId(),
                $authorizationCode->clientId(),
                $authorizationCode->scope(),
                $accessTokenExpiresAt,
                $authorizationCode->authorizationExpiresAt()
            );

            $refreshToken = new RefreshToken(
                Base64UrlSafe::encodeUnpadded($this->randomBytes()),
                $authorizationCode->authKey(),
                $authorizationCode->userId(),
                $authorizationCode->clientId(),
                $authorizationCode->scope(),
                $authorizationCode->authorizationExpiresAt()
            );

            $jsonData = [
                'access_token' => $this->signer->sign($accessToken->json()),
                'refresh_token' => $this->signer->sign($refreshToken->json()),
                'token_type' => 'bearer',
                'expires_in' => $this->expiresAtToInt($accessTokenExpiresAt),
            ];

            return new JsonResponse(
                $jsonData,
                // The authorization server MUST include the HTTP "Cache-Control"
                // response header field [RFC2616] with a value of "no-store" in any
                // response containing tokens, credentials, or other sensitive
                // information, as well as the "Pragma" response header field [RFC2616]
                // with a value of "no-cache".
                [
                    'Cache-Control' => 'no-store',
                    'Pragma' => 'no-cache',
                ]
            );
        } catch (SignerException $e) {
            throw new InvalidGrantException(sprintf('invalid "authorization_code": %s', $e->getMessage()));
        }
    }

    private function postTokenRefreshToken(ClientInfo $clientInfo, Request $request): JsonResponse
    {
        try {
            // verify the refresh code signature
            $refreshToken = RefreshToken::fromJson($this->signer->verify($request->post()->refreshToken()));

            // check refresh_token expiry
            if ($this->dateTime >= $refreshToken->expiresAt()) {
                throw new InvalidGrantException('"refresh_token" expired');
            }

            // make sure the token is bound to the client
            if ($clientInfo->clientId() !== $refreshToken->clientId()) {
                throw new InvalidRequestException('unexpected "client_id"');
            }

            // scope in POST body MUST match scope stored in the refresh_token when
            // provided
            if (null !== $postScope = $request->post()->scope()) {
                if (!$postScope->isEqual($refreshToken->scope())) {
                    throw new InvalidRequestException('unexpected "scope"');
                }
            }

            // make sure the authorization still exists
            if (null === $authorizationInfo = $this->storage->getAuthorization($refreshToken->authKey())) {
                throw new InvalidGrantException('"refresh_token" is no longer authorized');
            }

            // check for refresh_token replay
            if ($this->storage->isRefreshTokenReplay($refreshToken->tokenId())) {
                // it was used before, delete the complete authorization
                $this->storage->deleteAuthorization($refreshToken->authKey());

                throw new InvalidGrantException('"refresh_token" was used before');
            }

            // log the use of the refresh_token so it can't be used again
            $this->storage->logRefreshToken($refreshToken->authKey(), $refreshToken->tokenId());

            // make sure an access_token never outlives its authorization, i.e.
            // the refresh_token expiry
            $accessTokenExpiresAt = $this->dateTime->add($this->accessTokenExpiry);
            if ($accessTokenExpiresAt > $authorizationInfo->expiresAt()) {
                $accessTokenExpiresAt = $authorizationInfo->expiresAt();
            }

            $accessToken = new AccessToken(
                Base64UrlSafe::encodeUnpadded($this->randomBytes()),
                $refreshToken->authKey(),
                $refreshToken->userId(),
                $refreshToken->clientId(),
                $refreshToken->scope(),
                $accessTokenExpiresAt,
                $authorizationInfo->expiresAt()
            );

            $refreshToken = new RefreshToken(
                Base64UrlSafe::encodeUnpadded($this->randomBytes()),
                $refreshToken->authKey(),
                $refreshToken->userId(),
                $refreshToken->clientId(),
                $refreshToken->scope(),
                $authorizationInfo->expiresAt()
            );

            // The (successful) use of a "refresh_token" indicates that the
            // "authorization" is still active. It is much easier to store the
            // moment at which a refresh_token for a particular "authorization"
            // was used than every individual use of an "access_token"...
            $this->storage->updateAuthorization($refreshToken->authKey(), $this->dateTime);

            return new JsonResponse(
                [
                    'access_token' => $this->signer->sign($accessToken->json()),
                    'refresh_token' => $this->signer->sign($refreshToken->json()),
                    'token_type' => 'bearer',
                    'expires_in' => $this->expiresAtToInt($accessTokenExpiresAt),
                ],
                // The authorization server MUST include the HTTP "Cache-Control"
                // response header field [RFC2616] with a value of "no-store" in any
                // response containing tokens, credentials, or other sensitive
                // information, as well as the "Pragma" response header field [RFC2616]
                // with a value of "no-cache".
                [
                    'Cache-Control' => 'no-store',
                    'Pragma' => 'no-cache',
                ]
            );
        } catch (SignerException $e) {
            throw new InvalidGrantException(sprintf('invalid "refresh_token": %s', $e->getMessage()));
        }
    }

    /**
     * Identify and optionally authenticate the OAuth Client.
     *
     * "Confidential" clients require HTTP Basic authentication using
     * "username" and "password". "Public" clients identify themselves using
     * the HTTP POST "client_id" parameter.
     *
     * We make sure that "confidential" clients use HTTP Basic authentication,
     * and "public" clients the HTTP POST "client_id" parameter.
     *
     * Rant: this is a lot of code to do this, the specification could have
     * been better by always requiring the HTTP POST "client_id", or always
     * using HTTP Basic auth (possibly with empty "password") to identify
     * clients...
     */
    private function idAndAuthClient(?string $clientId, ?string $authUser, ?string $authPass): ClientInfo
    {
        if (null !== $authUser) {
            if (null !== $clientId) {
                if ($authUser !== $clientId) {
                    throw new InvalidClientException('HTTP Basic authentication "username" MUST match POST parameter "client_id" if the latter is specified');
                }
            }

            $clientInfo = $this->getClient($authUser);
            if (null === $clientSecret = $clientInfo->clientSecret()) {
                throw new InvalidClientException('for "public" clients HTTP Basic authentication MUST NOT be used');
            }
            if (null === $authPass) {
                throw new InvalidClientException('no "password" provided');
            }
            if (false === hash_equals($clientSecret, $authPass)) {
                throw new InvalidClientException('invalid "password" provided');
            }

            return $clientInfo;
        }

        if (null !== $clientId) {
            $clientInfo = $this->getClient($clientId);
            if (null !== $clientInfo->clientSecret()) {
                throw new InvalidClientException('for "confidential" clients HTTP Basic authentication MUST be used');
            }

            return $clientInfo;
        }

        throw new InvalidClientException('unable to identify the client');
    }

    /**
     * @see https://tools.ietf.org/html/rfc7636#appendix-A
     */
    private static function verifyPkce(string $codeVerifier, string $codeChallenge): bool
    {
        return hash_equals(
            $codeChallenge,
            Base64UrlSafe::encodeUnpadded(
                hash(
                    'sha256',
                    $codeVerifier,
                    true
                )
            )
        );
    }

    private function getClient(string $clientId): ClientInfo
    {
        if (null === $clientInfo = $this->clientDb->get($clientId)) {
            throw new InvalidClientException('client does not exist with this "client_id"');
        }

        return $clientInfo;
    }

    private function expiresAtToInt(DateTimeImmutable $expiresAt): int
    {
        return (int) ($expiresAt->getTimestamp() - $this->dateTime->getTimestamp());
    }

    /**
     * @param array<string,string> $queryParameters
     */
    private function prepareRedirectUri(string $redirectUri, array $queryParameters): string
    {
        return sprintf(
            '%s%s%s',
            $redirectUri,
            false === strpos($redirectUri, '?') ? '?' : '&',
            http_build_query($queryParameters)
        );
    }
}
