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

Add SameSite cookie attribute handling #400

Merged
merged 4 commits into from
Dec 2, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
<ruleset name="Auth0-PHP" namespace="Auth0PHP\CS\Standard">
<description>A custom coding standard for the Auth0 PHP SDK</description>

<file>.</file>

<exclude-pattern>/examples/*</exclude-pattern>
<exclude-pattern>/vendor/*</exclude-pattern>
<file>./src</file>

<!-- Only check PHP files. -->
<arg name="extensions" value="php"/>
Expand Down
6 changes: 5 additions & 1 deletion src/Auth0.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,11 @@ public function __construct(array $config)

$transientStore = $config['transient_store'] ?? null;
if (! $transientStore instanceof StoreInterface) {
$transientStore = new CookieStore();
$transientStore = new CookieStore([
// Use configuration option or class default.
'legacy_samesite_none' => $config['legacy_samesite_none_cookie'] ?? null,
'samesite' => 'form_post' === $this->responseMode ? 'None' : 'Lax',
]);
}

$this->transientHandler = new TransientStoreHandler( $transientStore );
Expand Down
180 changes: 160 additions & 20 deletions src/Store/CookieStore.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);

namespace Auth0\SDK\Store;

Expand All @@ -13,29 +14,75 @@ class CookieStore implements StoreInterface
const BASE_NAME = 'auth0_';

/**
* Cookie base name, configurable on instantiation.
* Cookie base name.
* Use config key 'base_name' to set this during instantiation.
* Default is self::BASE_NAME.
*
* @var string
*/
protected $cookie_base_name;
protected $baseName;

/**
* Cookie expiration, configurable on instantiation.
* Cookie expiration length, in seconds.
* This will be added to current time or $this->now to set cookie expiration time.
* Use config key 'expiration' to set this during instantiation.
* Default is 600.
*
* @var integer
*/
protected $cookie_expiration;
protected $expiration;

/**
* SameSite attribute for all cookies set with class instance.
* Must be one of None, Strict, Lax (default is no SameSite attribute).
* Use config key 'samesite' to set this during instantiation.
* Default is no SameSite attribute set.
*
* @var null|string
*/
protected $sameSite;

/**
* Time to use as "now" in expiration calculations.
* Used primarily for testing.
* Use config key 'now' to set this during instantiation.
* Default is current server time.
*
* @var null|integer
*/
protected $now;

/**
* Support legacy browsers for SameSite=None.
* This will set/get/delete a fallback cookie with no SameSite attribute if $this->sameSite is None.
* Use config key 'legacy_samesite_none' to set this during instantiation.
* Default is true.
*
* @var boolean
*/
protected $legacySameSiteNone;

/**
* CookieStore constructor.
*
* @param string $base_name Cookie base name.
* @param integer $expires Cookie expiration length, in seconds.
* @param array $options Cookie options. See class properties above for keys and types allowed.
*/
public function __construct(string $base_name = self::BASE_NAME, int $expires = 600)
public function __construct(array $options = [])
{
$this->cookie_base_name = $base_name;
$this->cookie_expiration = $expires;
$this->baseName = $options['base_name'] ?? self::BASE_NAME;
$this->expiration = $options['expiration'] ?? 600;
damieng marked this conversation as resolved.
Show resolved Hide resolved

if (! empty($options['samesite']) && is_string($options['samesite'])) {
$sameSite = ucfirst($options['samesite']);

if (in_array($sameSite, ['None', 'Strict', 'Lax'])) {
$this->sameSite = $sameSite;
}
}

$this->now = $options['now'] ?? null;

$this->legacySameSiteNone = $options['legacy_samesite_none'] ?? true;
damieng marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -50,7 +97,19 @@ public function set($key, $value)
{
$key_name = $this->getCookieName($key);
$_COOKIE[$key_name] = $value;
$this->setCookie( $key_name, $value, $this->cookie_expiration );

if ($this->sameSite) {
// Core setcookie() does not handle SameSite before PHP 7.3.
$this->setCookieHeader($key_name, $value, $this->getExpTimecode());
} else {
$this->setCookie($key_name, $value, $this->getExpTimecode());
}

// If we're using SameSite=None, set a fallback cookie with no SameSite attribute.
if ($this->legacySameSiteNone && 'None' === $this->sameSite) {
$_COOKIE['_'.$key_name] = $value;
$this->setCookie('_'.$key_name, $value, $this->getExpTimecode());
}
}

/**
Expand All @@ -65,7 +124,14 @@ public function set($key, $value)
public function get($key, $default = null)
{
$key_name = $this->getCookieName($key);
return $_COOKIE[$key_name] ?? $default;
$value = $default;

// If handling legacy browsers, check for fallback value.
if ($this->legacySameSiteNone) {
$value = $_COOKIE['_'.$key_name] ?? $value;
}

return $_COOKIE[$key_name] ?? $value;
}

/**
Expand All @@ -79,21 +145,95 @@ public function delete($key)
{
$key_name = $this->getCookieName($key);
unset($_COOKIE[$key_name]);
$this->setCookie( $key_name );
$this->setCookie( $key_name, '', 0 );

// If we set a legacy fallback value, remove that as well.
if ($this->legacySameSiteNone) {
unset($_COOKIE['_'.$key_name]);
$this->setCookie( '_'.$key_name, '', 0 );
}
}

/**
* Set or delete a cookie.
* Build the header to use when setting SameSite cookies.
*
* @param string $name Cookie name to set.
* @param string $value Cookie value.
* @param integer $expires Cookie expiration.
* @param string $name Cookie name.
* @param string $value Cookie value.
* @param integer $expire Cookie expiration timecode.
*
* @return string
*
* @see https://github.com/php/php-src/blob/master/ext/standard/head.c#L77
*/
protected function getSameSiteCookieHeader(string $name, string $value, int $expire) : string
{
$date = new \Datetime();
$date->setTimestamp($expire)
->setTimezone(new \DateTimeZone('GMT'));

$illegalChars = ",; \t\r\n\013\014";
$illegalCharsMsg = ",; \\t\\r\\n\\013\\014";

if (strpbrk($name, $illegalChars) != null) {
trigger_error("Cookie names cannot contain any of the following '{$illegalCharsMsg}'", E_USER_WARNING);
return '';
}

if (strpbrk($value, $illegalChars) != null) {
trigger_error("Cookie values cannot contain any of the following '{$illegalCharsMsg}'", E_USER_WARNING);
return '';
}

return sprintf(
'Set-Cookie: %s=%s; path=/; expires=%s; HttpOnly; SameSite=%s%s',
$name,
$value,
damieng marked this conversation as resolved.
Show resolved Hide resolved
$date->format($date::COOKIE),
$this->sameSite,
'None' === $this->sameSite ? '; Secure' : ''
);
}

/**
* Get cookie expiration timecode to use.
*
* @return integer
*/
protected function getExpTimecode() : int
{
return ($this->now ?? time()) + $this->expiration;
}

/**
* Wrapper around PHP core setcookie() function to assist with testing.
*
* @param string $name Complete cookie name to set.
* @param string $value Value of the cookie to set.
* @param integer $expire Expiration time in Unix timecode format.
*
* @return boolean
*
* @codeCoverageIgnore
*/
protected function setCookie(string $name, string $value, int $expire) : bool
{
return setcookie($name, $value, $expire, '/', '', false, true);
}

/**
* Wrapper around PHP core header() function to assist with testing.
*
* @param string $name Complete cookie name to set.
* @param string $value Value of the cookie to set.
* @param integer $expire Expiration time in Unix timecode format.
*
* @return void
*
* @codeCoverageIgnore
*/
protected function setCookie(string $name, string $value = '', int $expires = 0) : bool
protected function setCookieHeader(string $name, string $value, int $expire) : void
{
return setcookie($name, $value, time() + $expires, '/', '', false, true);
header($this->getSameSiteCookieHeader($name, $value, $expire), false);
}

/**
Expand All @@ -106,8 +246,8 @@ protected function setCookie(string $name, string $value = '', int $expires = 0)
public function getCookieName(string $key) : string
{
$key_name = $key;
if (! empty( $this->cookie_base_name )) {
$key_name = $this->cookie_base_name.'_'.$key_name;
if (! empty( $this->baseName )) {
$key_name = $this->baseName.'_'.$key_name;
}

return $key_name;
Expand Down
4 changes: 4 additions & 0 deletions src/Store/SessionStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

/**
* This class provides a layer to persist user access using PHP Sessions.
*
* NOTE: If you are using this storage method for the transient_store option in the Auth0 class along with a
* response_mode of form_post, the session cookie MUST be set to SameSite=None and Secure using
* session_set_cookie_params() or another method. This combination will be enforced by browsers in early 2020.
*/
class SessionStore implements StoreInterface
{
Expand Down
Loading