<?php

/**
 * Licensed to Jasig under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for
 * additional information regarding copyright ownership.
 *
 * Jasig licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file except in
 * compliance with the License. You may obtain a copy of the License at:
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * PHP Version 5
 *
 * @file     CAS/CookieJar.php
 * @category Authentication
 * @package  PhpCAS
 * @author   Adam Franco <afranco@middlebury.edu>
 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
 * @link     https://wiki.jasig.org/display/CASC/phpCAS
 */

/**
 * This class provides access to service cookies and handles parsing of response
 * headers to pull out cookie values.
 *
 * @class    CAS_CookieJar
 * @category Authentication
 * @package  PhpCAS
 * @author   Adam Franco <afranco@middlebury.edu>
 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
 * @link     https://wiki.jasig.org/display/CASC/phpCAS
 */
class CAS_CookieJar
{
    private $_cookies;

    /**
     * Create a new cookie jar by passing it a reference to an array in which it
     * should store cookies.
     *
     * @param array &$storageArray Array to store cookies
     *
     * @return void
     */
    public function __construct(array &$storageArray)
    {
        $this->_cookies =& $storageArray;
    }

    /**
     * Store cookies for a web service request.
     * Cookie storage is based on RFC 2965: http://www.ietf.org/rfc/rfc2965.txt
     *
     * @param string $request_url      The URL that generated the response headers.
     * @param array  $response_headers An array of the HTTP response header strings.
     *
     * @return void
     *
     * @access private
     */
    public function storeCookies($request_url, $response_headers)
    {
        $urlParts = parse_url($request_url);
        $defaultDomain = $urlParts['host'];

        $cookies = $this->parseCookieHeaders($response_headers, $defaultDomain);

        foreach ($cookies as $cookie) {
            // Enforce the same-origin policy by verifying that the cookie
            // would match the url that is setting it
            if (!$this->cookieMatchesTarget($cookie, $urlParts)) {
                continue;
            }

            // store the cookie
            $this->storeCookie($cookie);

            phpCAS::trace($cookie['name'].' -> '.$cookie['value']);
        }
    }

    /**
     * Retrieve cookies applicable for a web service request.
     * Cookie applicability is based on RFC 2965: http://www.ietf.org/rfc/rfc2965.txt
     *
     * @param string $request_url The url that the cookies will be for.
     *
     * @return array An array containing cookies. E.g. array('name' => 'val');
     *
     * @access private
     */
    public function getCookies($request_url)
    {
        if (!count($this->_cookies)) {
            return [];
        }

        // If our request URL can't be parsed, no cookies apply.
        $target = parse_url($request_url);
        if ($target === false) {
            return [];
        }

        $this->expireCookies();

        $matching_cookies = [];
        foreach ($this->_cookies as $key => $cookie) {
            if ($this->cookieMatchesTarget($cookie, $target)) {
                $matching_cookies[$cookie['name']] = $cookie['value'];
            }
        }
        return $matching_cookies;
    }


    /**
     * Parse Cookies without PECL
     * From the comments in http://php.net/manual/en/function.http-parse-cookie.php
     *
     * @param array  $header        array of header lines.
     * @param string $defaultDomain The domain to use if none is specified in
     * the cookie.
     *
     * @return array of cookies
     */
    protected function parseCookieHeaders($header, $defaultDomain)
    {
        phpCAS::traceBegin();
        $cookies = [];
        foreach ($header as $line) {
            if (preg_match('/^Set-Cookie2?: /i', $line)) {
                $cookies[] = $this->parseCookieHeader($line, $defaultDomain);
            }
        }

        phpCAS::traceEnd($cookies);
        return $cookies;
    }

    /**
     * Parse a single cookie header line.
     *
     * Based on RFC2965 http://www.ietf.org/rfc/rfc2965.txt
     *
     * @param string $line          The header line.
     * @param string $defaultDomain The domain to use if none is specified in
     * the cookie.
     *
     * @return array
     */
    protected function parseCookieHeader($line, $defaultDomain)
    {
        if (!$defaultDomain) {
            throw new CAS_InvalidArgumentException(
                '$defaultDomain was not provided.'
            );
        }

        // Set our default values
        $cookie = [
            'domain' => $defaultDomain,
            'path' => '/',
            'secure' => false,
        ];

        $line = preg_replace('/^Set-Cookie2?: /i', '', trim($line));

        // trim any trailing semicolons.
        $line = trim($line, ';');

        phpCAS::trace("Cookie Line: $line");

        // This implementation makes the assumption that semicolons will not
        // be present in quoted attribute values. While attribute values that
        // contain semicolons are allowed by RFC2965, they are hopefully rare
        // enough to ignore for our purposes. Most browsers make the same
        // assumption.
        $attributeStrings = explode(';', $line);

        foreach ($attributeStrings as $attributeString) {
            // split on the first equals sign and use the rest as value
            $attributeParts = explode('=', $attributeString, 2);

            $attributeName = trim($attributeParts[0]);
            $attributeNameLC = strtolower($attributeName);

            if (isset($attributeParts[1])) {
                $attributeValue = trim($attributeParts[1]);
                // Values may be quoted strings.
                if (strpos($attributeValue, '"') === 0) {
                    $attributeValue = trim($attributeValue, '"');
                    // unescape any escaped quotes:
                    $attributeValue = str_replace('\"', '"', $attributeValue);
                }
            } else {
                $attributeValue = null;
            }

            switch ($attributeNameLC) {
            case 'expires':
                $cookie['expires'] = strtotime($attributeValue);
                break;
            case 'max-age':
                $cookie['max-age'] = (int)$attributeValue;
                // Set an expiry time based on the max-age
                if ($cookie['max-age']) {
                    $cookie['expires'] = time() + $cookie['max-age'];
                } else {
                    // If max-age is zero, then the cookie should be removed
                    // imediately so set an expiry before now.
                    $cookie['expires'] = time() - 1;
                }
                break;
            case 'secure':
                $cookie['secure'] = true;
                break;
            case 'domain':
            case 'path':
            case 'port':
            case 'version':
            case 'comment':
            case 'commenturl':
            case 'discard':
            case 'httponly':
                $cookie[$attributeNameLC] = $attributeValue;
                break;
            default:
                $cookie['name'] = $attributeName;
                $cookie['value'] = $attributeValue;
            }
        }

        return $cookie;
    }

    /**
     * Add, update, or remove a cookie.
     *
     * @param array $cookie A cookie array as created by parseCookieHeaders()
     *
     * @return void
     *
     * @access protected
     */
    protected function storeCookie($cookie)
    {
        // Discard any old versions of this cookie.
        $this->discardCookie($cookie);
        $this->_cookies[] = $cookie;
    }

    /**
     * Discard an existing cookie
     *
     * @param array $cookie An cookie
     *
     * @return void
     *
     * @access protected
     */
    protected function discardCookie($cookie)
    {
        if (!isset($cookie['domain'])
            || !isset($cookie['path'])
            || !isset($cookie['path'])
        ) {
            throw new CAS_InvalidArgumentException('Invalid Cookie array passed.');
        }

        foreach ($this->_cookies as $key => $old_cookie) {
            if ($cookie['domain'] == $old_cookie['domain']
                && $cookie['path'] == $old_cookie['path']
                && $cookie['name'] == $old_cookie['name']
            ) {
                unset($this->_cookies[$key]);
            }
        }
    }

    /**
     * Go through our stored cookies and remove any that are expired.
     *
     * @return void
     *
     * @access protected
     */
    protected function expireCookies()
    {
        foreach ($this->_cookies as $key => $cookie) {
            if (isset($cookie['expires']) && $cookie['expires'] < time()) {
                unset($this->_cookies[$key]);
            }
        }
    }

    /**
     * Answer true if cookie is applicable to a target.
     *
     * @param array $cookie An array of cookie attributes.
     * @param array|false $target An array of URL attributes as generated by parse_url().
     *
     * @return bool
     *
     * @access private
     */
    protected function cookieMatchesTarget($cookie, $target)
    {
        if (!is_array($target)) {
            throw new CAS_InvalidArgumentException(
                '$target must be an array of URL attributes as generated by parse_url().'
            );
        }
        if (!isset($target['host'])) {
            throw new CAS_InvalidArgumentException(
                '$target must be an array of URL attributes as generated by parse_url().'
            );
        }

        // Verify that the scheme matches
        if ($cookie['secure'] && $target['scheme'] != 'https') {
            return false;
        }

        // Verify that the host matches
        // Match domain and mulit-host cookies
        if (strpos($cookie['domain'], '.') === 0) {
            // .host.domain.edu cookies are valid for host.domain.edu
            if (substr($cookie['domain'], 1) == $target['host']) {
                // continue with other checks
            } else {
                // non-exact host-name matches.
                // check that the target host a.b.c.edu is within .b.c.edu
                $pos = strripos($target['host'], $cookie['domain']);
                if (!$pos) {
                    return false;
                }
                // verify that the cookie domain is the last part of the host.
                if ($pos + strlen($cookie['domain']) != strlen($target['host'])) {
                    return false;
                }
                // verify that the host name does not contain interior dots as per
                // RFC 2965 section 3.3.2  Rejecting Cookies
                // http://www.ietf.org/rfc/rfc2965.txt
                $hostname = substr($target['host'], 0, $pos);
                if (strpos($hostname, '.') !== false) {
                    return false;
                }
            }
        } else {
            // If the cookie host doesn't begin with '.',
            // the host must case-insensitive match exactly
            if (strcasecmp($target['host'], $cookie['domain']) !== 0) {
                return false;
            }
        }

        // Verify that the port matches
        if (isset($cookie['ports'])
            && !in_array($target['port'], $cookie['ports'])
        ) {
            return false;
        }

        // Verify that the path matches
        if (strpos($target['path'], $cookie['path']) !== 0) {
            return false;
        }

        return true;
    }
}
